Skip to content

Commit af1caeb

Browse files
claudetrieloff
authored andcommitted
feat: implement context.log with Fastly logger multiplexing and Cloudflare tail worker support
Implements issue #79 by adding unified logging API to both Fastly and Cloudflare adapters. **Fastly Implementation:** - Uses fastly:logger module for native logger support - Multiplexes log entries to all configured logger endpoints - Falls back to console.log when no loggers configured - Async logger initialization with graceful error handling **Cloudflare Implementation:** - Emits console.log with target field for tail worker filtering - One log entry per configured target - Each entry includes target field for tail worker routing **Unified API:** - context.log.debug(data) - context.log.info(data) - context.log.warn(data) - context.log.error(data) - Supports both structured objects and plain strings - Plain strings auto-converted to { message: string } format **Auto-enrichment:** - timestamp (ISO format) - level (debug/info/warn/error) - requestId, transactionId - functionName, functionVersion, functionFQN - region (edge POP/colo) **Configuration:** context.attributes.loggers = ['target1', 'target2'] **Implementation Details:** - New module: src/template/context-logger.js with logger factories - Updated: src/template/fastly-adapter.js with Fastly logger integration - Updated: src/template/cloudflare-adapter.js with Cloudflare logging - Added context.attributes property to both adapters - Comprehensive test coverage for all logging scenarios Closes #79 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Lars Trieloff <lars@trieloff.net>
1 parent 3a7f63a commit af1caeb

File tree

5 files changed

+499
-0
lines changed

5 files changed

+499
-0
lines changed

src/template/cloudflare-adapter.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
*/
1212
/* eslint-env serviceworker */
1313
import { extractPathFromURL } from './adapter-utils.js';
14+
import { createCloudflareLogger } from './context-logger.js';
1415

1516
export async function handleRequest(event) {
1617
try {
@@ -44,7 +45,13 @@ export async function handleRequest(event) {
4445
get: (target, prop) => target[prop] || target.PACKAGE.get(prop),
4546
}),
4647
storage: null,
48+
attributes: {},
4749
};
50+
51+
// Initialize logger after context is created
52+
// Logger configuration can be set via context.attributes.loggers
53+
context.log = createCloudflareLogger(context.attributes.loggers, context);
54+
4855
return await main(request, context);
4956
} catch (e) {
5057
console.log(e.message);

src/template/context-logger.js

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
/* eslint-env serviceworker */
13+
14+
/**
15+
* Normalizes log input to always be an object.
16+
* Converts string inputs to { message: string } format.
17+
* @param {*} data - The log data (string or object)
18+
* @returns {object} Normalized log object
19+
*/
20+
export function normalizeLogData(data) {
21+
if (typeof data === 'string') {
22+
return { message: data };
23+
}
24+
if (typeof data === 'object' && data !== null) {
25+
return { ...data };
26+
}
27+
return { message: String(data) };
28+
}
29+
30+
/**
31+
* Enriches log data with context metadata.
32+
* @param {object} data - The log data object
33+
* @param {string} level - The log level (debug, info, warn, error)
34+
* @param {object} context - The context object with metadata
35+
* @returns {object} Enriched log object
36+
*/
37+
export function enrichLogData(data, level, context) {
38+
return {
39+
timestamp: new Date().toISOString(),
40+
level,
41+
requestId: context.invocation?.requestId,
42+
transactionId: context.invocation?.transactionId,
43+
functionName: context.func?.name,
44+
functionVersion: context.func?.version,
45+
functionFQN: context.func?.fqn,
46+
region: context.runtime?.region,
47+
...data,
48+
};
49+
}
50+
51+
/**
52+
* Creates a logger instance for Fastly using fastly:logger module.
53+
* Uses async import and handles initialization.
54+
* @param {string[]} loggerNames - Array of logger endpoint names
55+
* @param {object} context - The context object
56+
* @returns {object} Logger instance with level methods
57+
*/
58+
export function createFastlyLogger(loggerNames, context) {
59+
const loggers = [];
60+
let loggersReady = false;
61+
let loggerPromise = null;
62+
63+
// Initialize Fastly loggers asynchronously
64+
if (loggerNames && loggerNames.length > 0) {
65+
loggerPromise = import('fastly:logger').then((module) => {
66+
loggerNames.forEach((name) => {
67+
try {
68+
loggers.push(new module.Logger(name));
69+
} catch (err) {
70+
console.error(`Failed to create Fastly logger "${name}": ${err.message}`);
71+
}
72+
});
73+
loggersReady = true;
74+
loggerPromise = null;
75+
}).catch((err) => {
76+
console.error(`Failed to import fastly:logger: ${err.message}`);
77+
loggersReady = true;
78+
loggerPromise = null;
79+
});
80+
} else {
81+
// No loggers configured, mark as ready immediately
82+
loggersReady = true;
83+
}
84+
85+
/**
86+
* Sends a log entry to all configured Fastly loggers.
87+
* @param {string} level - Log level
88+
* @param {*} data - Log data
89+
*/
90+
const log = (level, data) => {
91+
const normalizedData = normalizeLogData(data);
92+
const enrichedData = enrichLogData(normalizedData, level, context);
93+
const logEntry = JSON.stringify(enrichedData);
94+
95+
// If loggers are still initializing, wait for them
96+
if (loggerPromise) {
97+
loggerPromise.then(() => {
98+
if (loggers.length > 0) {
99+
loggers.forEach((logger) => {
100+
try {
101+
logger.log(logEntry);
102+
} catch (err) {
103+
console.error(`Failed to log to Fastly logger: ${err.message}`);
104+
}
105+
});
106+
} else {
107+
// Fallback to console if no loggers configured
108+
console.log(logEntry);
109+
}
110+
});
111+
} else if (loggersReady) {
112+
if (loggers.length > 0) {
113+
loggers.forEach((logger) => {
114+
try {
115+
logger.log(logEntry);
116+
} catch (err) {
117+
console.error(`Failed to log to Fastly logger: ${err.message}`);
118+
}
119+
});
120+
} else {
121+
// Fallback to console if no loggers configured
122+
console.log(logEntry);
123+
}
124+
}
125+
};
126+
127+
return {
128+
debug: (data) => log('debug', data),
129+
info: (data) => log('info', data),
130+
warn: (data) => log('warn', data),
131+
error: (data) => log('error', data),
132+
};
133+
}
134+
135+
/**
136+
* Creates a logger instance for Cloudflare that emits console logs
137+
* with target field for tail worker filtering.
138+
* @param {string[]} loggerNames - Array of logger target names
139+
* @param {object} context - The context object
140+
* @returns {object} Logger instance with level methods
141+
*/
142+
export function createCloudflareLogger(loggerNames, context) {
143+
/**
144+
* Sends a log entry to console for each configured target.
145+
* Each entry includes a 'target' field for tail worker filtering.
146+
* @param {string} level - Log level
147+
* @param {*} data - Log data
148+
*/
149+
const log = (level, data) => {
150+
const normalizedData = normalizeLogData(data);
151+
const enrichedData = enrichLogData(normalizedData, level, context);
152+
153+
if (loggerNames && loggerNames.length > 0) {
154+
// Emit one log per target for tail worker filtering
155+
loggerNames.forEach((target) => {
156+
const logEntry = JSON.stringify({
157+
target,
158+
...enrichedData,
159+
});
160+
console.log(logEntry);
161+
});
162+
} else {
163+
// No targets configured, just log to console
164+
console.log(JSON.stringify(enrichedData));
165+
}
166+
};
167+
168+
return {
169+
debug: (data) => log('debug', data),
170+
info: (data) => log('info', data),
171+
warn: (data) => log('warn', data),
172+
error: (data) => log('error', data),
173+
};
174+
}

src/template/fastly-adapter.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
/* eslint-env serviceworker */
1313
/* global Dictionary, CacheOverride */
1414
import { extractPathFromURL } from './adapter-utils.js';
15+
import { createFastlyLogger } from './context-logger.js';
1516

1617
export function getEnvInfo(req, env) {
1718
const serviceVersion = env('FASTLY_SERVICE_VERSION');
@@ -108,7 +109,13 @@ export async function handleRequest(event) {
108109
},
109110
}),
110111
storage: null,
112+
attributes: {},
111113
};
114+
115+
// Initialize logger after context is created
116+
// Logger configuration can be set via context.attributes.loggers
117+
context.log = createFastlyLogger(context.attributes.loggers, context);
118+
112119
return await main(request, context);
113120
} catch (e) {
114121
console.log(e.message);

test/cloudflare-adapter.test.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,98 @@ describe('Cloudflare Adapter Test', () => {
2828
it('returns null in a non-cloudflare environment', () => {
2929
assert.strictEqual(adapter(), null);
3030
});
31+
32+
it('creates context with log property', async () => {
33+
const logs = [];
34+
const originalLog = console.log;
35+
console.log = (msg) => {
36+
// Only capture JSON logs from our logger
37+
try {
38+
logs.push(JSON.parse(msg));
39+
} catch {
40+
// Ignore non-JSON logs
41+
}
42+
};
43+
44+
try {
45+
const request = {
46+
url: 'https://example.com/test',
47+
cf: { colo: 'SFO' },
48+
};
49+
50+
const mockMain = (req, ctx) => {
51+
// Verify context has log property with methods
52+
assert.ok(ctx.log);
53+
assert.ok(typeof ctx.log.info === 'function');
54+
assert.ok(typeof ctx.log.error === 'function');
55+
assert.ok(typeof ctx.log.warn === 'function');
56+
assert.ok(typeof ctx.log.debug === 'function');
57+
58+
// Test logging
59+
ctx.log.info({ test: 'data' });
60+
61+
return new Response('ok');
62+
};
63+
64+
// Mock the main module
65+
global.require = () => ({ main: mockMain });
66+
67+
await handleRequest({ request });
68+
69+
// Verify log was emitted
70+
assert.strictEqual(logs.length, 1);
71+
assert.strictEqual(logs[0].level, 'info');
72+
assert.strictEqual(logs[0].test, 'data');
73+
} finally {
74+
console.log = originalLog;
75+
delete global.require;
76+
}
77+
});
78+
79+
it('includes target field when loggers configured', async () => {
80+
const logs = [];
81+
const originalLog = console.log;
82+
console.log = (msg) => {
83+
try {
84+
logs.push(JSON.parse(msg));
85+
} catch {
86+
// Ignore non-JSON logs
87+
}
88+
};
89+
90+
try {
91+
const request = {
92+
url: 'https://example.com/test',
93+
cf: { colo: 'LAX' },
94+
};
95+
96+
const mockMain = async (req, ctx) => {
97+
// Configure loggers
98+
ctx.attributes.loggers = ['coralogix', 'splunk'];
99+
100+
// Re-initialize logger with new configuration
101+
const { createCloudflareLogger } = await import('../src/template/context-logger.js');
102+
ctx.log = createCloudflareLogger(ctx.attributes.loggers, ctx);
103+
104+
// Log message
105+
ctx.log.error('test error');
106+
107+
return new Response('ok');
108+
};
109+
110+
global.require = () => ({ main: mockMain });
111+
112+
await handleRequest({ request });
113+
114+
// Verify two logs emitted (one per target)
115+
assert.strictEqual(logs.length, 2);
116+
assert.strictEqual(logs[0].target, 'coralogix');
117+
assert.strictEqual(logs[0].message, 'test error');
118+
assert.strictEqual(logs[1].target, 'splunk');
119+
assert.strictEqual(logs[1].message, 'test error');
120+
} finally {
121+
console.log = originalLog;
122+
delete global.require;
123+
}
124+
});
31125
});

0 commit comments

Comments
 (0)