autotel-cloudflare 17.0.0 → 18.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -0
- package/dist/actors.d.ts +7 -7
- package/dist/agents.d.ts +103 -103
- package/dist/{handlers-BRfAgfP7.d.ts → handlers-CEAD5hVy.d.ts} +3 -1
- package/dist/{handlers-4X_6aEtg.js → handlers-a5qrd5nR.js} +89 -12
- package/dist/handlers.d.ts +2 -2
- package/dist/handlers.js +2 -2
- package/dist/index.d.ts +8 -8
- package/dist/index.js +14 -7
- package/dist/native-tracing-DjyDA54H.js +63 -0
- package/dist/native.js +1 -1
- package/dist/tail.d.ts +110 -0
- package/dist/tail.js +249 -0
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +0 -2
- package/package.json +18 -14
- package/dist/native-tracing-D0EyAtsF.js +0 -32
package/dist/tail.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
//#region src/tail.ts
|
|
2
|
+
/**
|
|
3
|
+
* `autotel-cloudflare/tail`: observability for Workers with no SDK in them.
|
|
4
|
+
*
|
|
5
|
+
* A Tail Worker receives every invocation of the Workers that list it in
|
|
6
|
+
* `tail_consumers`: outcome, uncaught exceptions, console output (with the
|
|
7
|
+
* exception behind each `console.error` arg), the fetch request and response
|
|
8
|
+
* status, the version that ran. This turns each invocation into one OTLP span
|
|
9
|
+
* and its console output into OTLP logs, and sends them to any OTLP endpoint
|
|
10
|
+
* (autotel-devtools, the autotel-mcp collector, a vendor). Issues, grouping,
|
|
11
|
+
* source maps and automations then work exactly as for an instrumented app.
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* // tail-worker/src/index.ts
|
|
15
|
+
* import { createTailHandler } from 'autotel-cloudflare/tail';
|
|
16
|
+
* export default {
|
|
17
|
+
* tail: createTailHandler({ endpoint: (env) => env.OTLP_ENDPOINT }),
|
|
18
|
+
* };
|
|
19
|
+
* ```
|
|
20
|
+
* ```jsonc
|
|
21
|
+
* // the observed Worker's wrangler.jsonc: no code change
|
|
22
|
+
* { "tail_consumers": [{ "service": "my-tail-worker" }] }
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Dependency-free on purpose: a tail Worker runs for every invocation of
|
|
26
|
+
* everything it watches, so it should be as small as its job.
|
|
27
|
+
*/
|
|
28
|
+
/** `exception.type` autotel uses for its log-flood and runaway-alarm reports. */
|
|
29
|
+
const LOG_FLOOD = "autotel.LogFlood";
|
|
30
|
+
const RUNAWAY_ALARM = "autotel.RunawayAlarm";
|
|
31
|
+
function anyValue(value) {
|
|
32
|
+
if (typeof value === "boolean") return { boolValue: value };
|
|
33
|
+
if (typeof value === "number") return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value };
|
|
34
|
+
return { stringValue: typeof value === "string" ? value : JSON.stringify(value) };
|
|
35
|
+
}
|
|
36
|
+
function attrs(record) {
|
|
37
|
+
return Object.entries(record).filter(([, value]) => value !== void 0 && value !== null && value !== "").map(([key, value]) => ({
|
|
38
|
+
key,
|
|
39
|
+
value: anyValue(value)
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
const nanos = (ms) => `${Math.round(ms)}000000`;
|
|
43
|
+
function hex(bytes) {
|
|
44
|
+
const buffer = new Uint8Array(bytes);
|
|
45
|
+
crypto.getRandomValues(buffer);
|
|
46
|
+
return [...buffer].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
47
|
+
}
|
|
48
|
+
/** Join the caller's trace when the request carried a W3C `traceparent`. */
|
|
49
|
+
function parentOf(headers) {
|
|
50
|
+
const header = headers?.traceparent ?? headers?.Traceparent;
|
|
51
|
+
const match = header && /^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/.exec(header);
|
|
52
|
+
return match ? {
|
|
53
|
+
traceId: match[1],
|
|
54
|
+
parentSpanId: match[2]
|
|
55
|
+
} : void 0;
|
|
56
|
+
}
|
|
57
|
+
function format(message) {
|
|
58
|
+
return (Array.isArray(message) ? message : [message]).map((part) => typeof part === "string" ? part : JSON.stringify(part)).join(" ").slice(0, 2e3);
|
|
59
|
+
}
|
|
60
|
+
/** Collapse ids and numbers so one loop's lines share a template. */
|
|
61
|
+
function logTemplate(message) {
|
|
62
|
+
return format(message).replaceAll(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<uuid>").replaceAll(/\b(?=[0-9a-f]*\d)[0-9a-f]{8,}\b/gi, "<hex>").replaceAll(/\d+/g, "<n>");
|
|
63
|
+
}
|
|
64
|
+
const SEVERITY = {
|
|
65
|
+
debug: [5, "DEBUG"],
|
|
66
|
+
log: [9, "INFO"],
|
|
67
|
+
info: [9, "INFO"],
|
|
68
|
+
warn: [13, "WARN"],
|
|
69
|
+
error: [17, "ERROR"]
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Alarm runs per Durable Object, per isolate: best effort, as long as a tail
|
|
73
|
+
* isolate lives. Keyed by when each alarm *ran* (the invocation's timestamp),
|
|
74
|
+
* never when the tail received it: events arrive batched, late and out of order.
|
|
75
|
+
*/
|
|
76
|
+
const alarmRuns = /* @__PURE__ */ new Map();
|
|
77
|
+
function alarmTimestamp(item, now) {
|
|
78
|
+
const scheduled = item.event?.scheduledTime;
|
|
79
|
+
return item.eventTimestamp ?? (scheduled instanceof Date ? scheduled.getTime() : scheduled) ?? now;
|
|
80
|
+
}
|
|
81
|
+
function runawayAlarm(item, options, now) {
|
|
82
|
+
const limits = options.runawayAlarm ?? {
|
|
83
|
+
maxRuns: 10,
|
|
84
|
+
windowMs: 6e4
|
|
85
|
+
};
|
|
86
|
+
if (!limits || !item.durableObjectId || item.event?.scheduledTime === void 0 || item.event.cron !== void 0) return;
|
|
87
|
+
const at = alarmTimestamp(item, now);
|
|
88
|
+
const state = alarmRuns.get(item.durableObjectId) ?? { runs: [] };
|
|
89
|
+
const newest = Math.max(at, ...state.runs);
|
|
90
|
+
state.runs = [...state.runs, at].filter((t) => newest - t < 2 * limits.windowMs).sort((a, b) => a - b).slice(-1e3);
|
|
91
|
+
if (alarmRuns.size > 1e3 && !alarmRuns.has(item.durableObjectId)) alarmRuns.clear();
|
|
92
|
+
alarmRuns.set(item.durableObjectId, state);
|
|
93
|
+
let busiest = 0;
|
|
94
|
+
for (const startAt of state.runs) {
|
|
95
|
+
if (startAt > at || at - startAt >= limits.windowMs) continue;
|
|
96
|
+
const count = state.runs.filter((t) => t >= startAt && t - startAt < limits.windowMs).length;
|
|
97
|
+
busiest = Math.max(busiest, count);
|
|
98
|
+
}
|
|
99
|
+
if (busiest <= limits.maxRuns) return void 0;
|
|
100
|
+
if (state.reportedAt !== void 0 && Math.abs(at - state.reportedAt) < limits.windowMs) return;
|
|
101
|
+
state.reportedAt = at;
|
|
102
|
+
return `Durable Object ${item.durableObjectId} alarm ran ${busiest} times in ${limits.windowMs / 1e3}s`;
|
|
103
|
+
}
|
|
104
|
+
function spanName(item) {
|
|
105
|
+
const event = item.event;
|
|
106
|
+
if (event?.request) try {
|
|
107
|
+
return `${event.request.method} ${new URL(event.request.url).pathname}`;
|
|
108
|
+
} catch {
|
|
109
|
+
return event.request.method;
|
|
110
|
+
}
|
|
111
|
+
if (event?.cron !== void 0) return `scheduled ${event.cron}`;
|
|
112
|
+
if (event?.queue) return `queue ${event.queue}`;
|
|
113
|
+
if (event?.rpcMethod) return `rpc ${event.rpcMethod}`;
|
|
114
|
+
if (item.durableObjectId && event?.scheduledTime !== void 0) return "alarm";
|
|
115
|
+
return item.entrypoint ?? "invocation";
|
|
116
|
+
}
|
|
117
|
+
/** Pure conversion: Cloudflare tail events → OTLP/JSON traces and logs. */
|
|
118
|
+
function tailItemsToOtlp(items, options = {}, now = Date.now()) {
|
|
119
|
+
const resourceSpans = [];
|
|
120
|
+
const resourceLogs = [];
|
|
121
|
+
const threshold = options.logFloodThreshold ?? 100;
|
|
122
|
+
for (const item of items) {
|
|
123
|
+
const start = item.eventTimestamp ?? item.logs[0]?.timestamp ?? now;
|
|
124
|
+
const end = start + Math.max(item.wallTime ?? 0, 0);
|
|
125
|
+
const parent = parentOf(item.event?.request?.headers);
|
|
126
|
+
const traceId = parent?.traceId ?? hex(16);
|
|
127
|
+
const spanId = hex(8);
|
|
128
|
+
const status = item.event?.response?.status;
|
|
129
|
+
const failed = item.outcome !== "ok" || item.exceptions.length > 0;
|
|
130
|
+
const exceptionEvent = (time, name, message, stack) => ({
|
|
131
|
+
name: "exception",
|
|
132
|
+
timeUnixNano: nanos(time),
|
|
133
|
+
attributes: attrs({
|
|
134
|
+
"exception.type": name,
|
|
135
|
+
"exception.message": message,
|
|
136
|
+
"exception.stacktrace": stack
|
|
137
|
+
})
|
|
138
|
+
});
|
|
139
|
+
const events = item.exceptions.map((e) => exceptionEvent(e.timestamp, e.name, e.message, e.stack));
|
|
140
|
+
const templates = /* @__PURE__ */ new Map();
|
|
141
|
+
for (const log of item.logs) {
|
|
142
|
+
if (log.level === "error") {
|
|
143
|
+
const errors = (log.errorInfo ?? []).filter((e) => e !== null);
|
|
144
|
+
if (errors.length > 0) for (const e of errors) events.push(exceptionEvent(log.timestamp, e.name, e.message, e.stack));
|
|
145
|
+
else events.push(exceptionEvent(log.timestamp, "Error", format(log.message).slice(0, 500)));
|
|
146
|
+
}
|
|
147
|
+
if (threshold > 0) {
|
|
148
|
+
const template = logTemplate(log.message);
|
|
149
|
+
const count = (templates.get(template) ?? 0) + 1;
|
|
150
|
+
templates.set(template, count);
|
|
151
|
+
if (count === threshold + 1) events.push(exceptionEvent(log.timestamp, LOG_FLOOD, `"${template.slice(0, 120)}" logged ${threshold}+ times in one invocation`));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const runaway = runawayAlarm(item, options, now);
|
|
155
|
+
if (runaway) events.push(exceptionEvent(start, RUNAWAY_ALARM, runaway));
|
|
156
|
+
const resource = { attributes: attrs({
|
|
157
|
+
"service.name": item.scriptName ?? options.serviceName ?? "worker",
|
|
158
|
+
"service.version": item.scriptVersion?.tag ?? item.scriptVersion?.id,
|
|
159
|
+
"cloudflare.script_version.id": item.scriptVersion?.id,
|
|
160
|
+
"cloud.provider": "cloudflare",
|
|
161
|
+
"cloud.platform": "cloudflare.workers",
|
|
162
|
+
"telemetry.sdk.name": "autotel-cloudflare/tail"
|
|
163
|
+
}) };
|
|
164
|
+
let url;
|
|
165
|
+
try {
|
|
166
|
+
url = item.event?.request ? new URL(item.event.request.url) : void 0;
|
|
167
|
+
} catch {
|
|
168
|
+
url = void 0;
|
|
169
|
+
}
|
|
170
|
+
resourceSpans.push({
|
|
171
|
+
resource,
|
|
172
|
+
scopeSpans: [{
|
|
173
|
+
scope: { name: "autotel-cloudflare/tail" },
|
|
174
|
+
spans: [{
|
|
175
|
+
traceId,
|
|
176
|
+
spanId,
|
|
177
|
+
...parent ? { parentSpanId: parent.parentSpanId } : {},
|
|
178
|
+
name: spanName(item),
|
|
179
|
+
kind: item.event?.request ? 2 : 1,
|
|
180
|
+
startTimeUnixNano: nanos(start),
|
|
181
|
+
endTimeUnixNano: nanos(end),
|
|
182
|
+
attributes: attrs({
|
|
183
|
+
...item.tailAttributes,
|
|
184
|
+
"http.request.method": item.event?.request?.method,
|
|
185
|
+
"url.full": item.event?.request?.url,
|
|
186
|
+
"url.path": url?.pathname,
|
|
187
|
+
"http.response.status_code": status,
|
|
188
|
+
"cloudflare.outcome": item.outcome,
|
|
189
|
+
"cloudflare.entrypoint": item.entrypoint,
|
|
190
|
+
"cloudflare.durable_object.id": item.durableObjectId,
|
|
191
|
+
"cloudflare.cpu_time_ms": item.cpuTime,
|
|
192
|
+
"cloudflare.truncated": item.truncated || void 0,
|
|
193
|
+
"faas.trigger": item.event?.request ? "http" : item.event?.cron === void 0 ? item.event?.queue ? "pubsub" : "other" : "timer"
|
|
194
|
+
}),
|
|
195
|
+
events,
|
|
196
|
+
status: failed ? {
|
|
197
|
+
code: 2,
|
|
198
|
+
message: item.exceptions[0]?.message ?? `outcome: ${item.outcome}`
|
|
199
|
+
} : { code: 0 }
|
|
200
|
+
}]
|
|
201
|
+
}]
|
|
202
|
+
});
|
|
203
|
+
if (item.logs.length > 0) resourceLogs.push({
|
|
204
|
+
resource,
|
|
205
|
+
scopeLogs: [{
|
|
206
|
+
scope: { name: "autotel-cloudflare/tail" },
|
|
207
|
+
logRecords: item.logs.map((log) => {
|
|
208
|
+
const [severityNumber, severityText] = SEVERITY[log.level] ?? [9, "INFO"];
|
|
209
|
+
return {
|
|
210
|
+
timeUnixNano: nanos(log.timestamp),
|
|
211
|
+
severityNumber,
|
|
212
|
+
severityText,
|
|
213
|
+
body: { stringValue: format(log.message) },
|
|
214
|
+
traceId,
|
|
215
|
+
spanId
|
|
216
|
+
};
|
|
217
|
+
})
|
|
218
|
+
}]
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
traces: { resourceSpans },
|
|
223
|
+
logs: { resourceLogs }
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/** A Worker `tail` handler that ships every observed invocation as OTLP. */
|
|
227
|
+
function createTailHandler(options) {
|
|
228
|
+
return async (events, env, ctx) => {
|
|
229
|
+
const endpoint = typeof options.endpoint === "function" ? options.endpoint(env) : options.endpoint;
|
|
230
|
+
if (!endpoint || events.length === 0) return;
|
|
231
|
+
const headers = {
|
|
232
|
+
"content-type": "application/json",
|
|
233
|
+
...typeof options.headers === "function" ? options.headers(env) : options.headers
|
|
234
|
+
};
|
|
235
|
+
const { traces, logs } = tailItemsToOtlp(events, options);
|
|
236
|
+
const base = endpoint.replace(/\/+$/, "").replace(/\/v1\/(traces|logs)$/, "");
|
|
237
|
+
const send = (path, body) => fetch(`${base}${path}`, {
|
|
238
|
+
method: "POST",
|
|
239
|
+
headers,
|
|
240
|
+
body: JSON.stringify(body)
|
|
241
|
+
}).then((res) => {
|
|
242
|
+
if (!res.ok) console.warn(`[autotel-cloudflare/tail] ${path} → HTTP ${res.status}`);
|
|
243
|
+
});
|
|
244
|
+
ctx.waitUntil(Promise.all([send("/v1/traces", traces), logs.resourceLogs.length > 0 ? send("/v1/logs", logs) : void 0]).catch((error) => console.warn("[autotel-cloudflare/tail] export failed", error)));
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
//#endregion
|
|
249
|
+
export { createTailHandler, logTemplate, tailItemsToOtlp };
|
package/dist/testing.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "autotel-edge/testing";
|
|
1
|
+
export type * from "autotel-edge/testing";
|
package/dist/testing.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "autotel-cloudflare",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "18.1.0",
|
|
4
4
|
"description": "The #1 OpenTelemetry package for Cloudflare Workers - complete bindings coverage, native CF OTel integration, advanced sampling",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -47,6 +47,10 @@
|
|
|
47
47
|
"types": "./dist/native.d.ts",
|
|
48
48
|
"import": "./dist/native.js"
|
|
49
49
|
},
|
|
50
|
+
"./tail": {
|
|
51
|
+
"types": "./dist/tail.d.ts",
|
|
52
|
+
"import": "./dist/tail.js"
|
|
53
|
+
},
|
|
50
54
|
"./parse-error": {
|
|
51
55
|
"types": "./dist/parse-error.d.ts",
|
|
52
56
|
"import": "./dist/parse-error.js"
|
|
@@ -80,9 +84,9 @@
|
|
|
80
84
|
"@opentelemetry/resources": "^2.11.0"
|
|
81
85
|
},
|
|
82
86
|
"peerDependencies": {
|
|
83
|
-
"@cloudflare/workers-types": "^5.
|
|
84
|
-
"autotel-edge": "
|
|
85
|
-
"autotel-genai": "0.
|
|
87
|
+
"@cloudflare/workers-types": "^5.20260928.1",
|
|
88
|
+
"autotel-edge": "6.0.0",
|
|
89
|
+
"autotel-genai": "0.15.1"
|
|
86
90
|
},
|
|
87
91
|
"peerDependenciesMeta": {
|
|
88
92
|
"autotel-edge": {
|
|
@@ -93,16 +97,16 @@
|
|
|
93
97
|
}
|
|
94
98
|
},
|
|
95
99
|
"devDependencies": {
|
|
96
|
-
"@cloudflare/workers-types": "^5.
|
|
100
|
+
"@cloudflare/workers-types": "^5.20260928.1",
|
|
97
101
|
"@opentelemetry/context-async-hooks": "^2.11.0",
|
|
98
|
-
"@types/node": "^26.
|
|
99
|
-
"autotel-edge": "
|
|
100
|
-
"autotel-genai": "0.
|
|
102
|
+
"@types/node": "^26.6.3",
|
|
103
|
+
"autotel-edge": "6.0.0",
|
|
104
|
+
"autotel-genai": "0.15.1",
|
|
101
105
|
"rimraf": "^6.1.3",
|
|
102
|
-
"tsdown": "^0.
|
|
103
|
-
"typescript": "^
|
|
104
|
-
"vitest": "^
|
|
105
|
-
"vitest-mock-extended": "^5.1.
|
|
106
|
+
"tsdown": "^0.23.0",
|
|
107
|
+
"typescript": "^7.0.2",
|
|
108
|
+
"vitest": "^5.0.2",
|
|
109
|
+
"vitest-mock-extended": "^5.1.1"
|
|
106
110
|
},
|
|
107
111
|
"repository": {
|
|
108
112
|
"type": "git",
|
|
@@ -116,8 +120,8 @@
|
|
|
116
120
|
"scripts": {
|
|
117
121
|
"build": "tsdown",
|
|
118
122
|
"dev": "tsdown --watch",
|
|
119
|
-
"lint": "
|
|
120
|
-
"lint:fix": "
|
|
123
|
+
"lint": "oxlint -c oxlint.json --disable-nested-config src",
|
|
124
|
+
"lint:fix": "pnpm lint --fix",
|
|
121
125
|
"type-check": "tsc --noEmit",
|
|
122
126
|
"test": "vitest run",
|
|
123
127
|
"test:watch": "vitest",
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
//#region src/native/native-tracing.ts
|
|
2
|
-
function readTracing(carrier) {
|
|
3
|
-
const tracing = carrier?.tracing;
|
|
4
|
-
return typeof tracing?.enterSpan === "function" ? tracing : void 0;
|
|
5
|
-
}
|
|
6
|
-
/**
|
|
7
|
-
* Returns `true` when Cloudflare native custom-span tracing is available on the
|
|
8
|
-
* given ExecutionContext (i.e. tracing is enabled for this Worker).
|
|
9
|
-
*/
|
|
10
|
-
function isNativeTracingAvailable(ctx) {
|
|
11
|
-
return readTracing(ctx) !== void 0;
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* Wrap Cloudflare's `ctx.tracing` as an autotel {@link NativeTracer}, or return
|
|
15
|
-
* `null` when native tracing is unavailable on this context.
|
|
16
|
-
*
|
|
17
|
-
* @param correlationId Optional per-request id (e.g. the `cf-ray` header)
|
|
18
|
-
* surfaced as `ctx.correlationId` and a `correlation.id` span attribute, so
|
|
19
|
-
* logs, custom spans, and the Cloudflare dashboard share one queryable key
|
|
20
|
-
* today — before Cloudflare exposes in-code trace/span ids.
|
|
21
|
-
*/
|
|
22
|
-
function getNativeTracerFromCtx(ctx, correlationId) {
|
|
23
|
-
const tracing = readTracing(ctx);
|
|
24
|
-
if (!tracing) return null;
|
|
25
|
-
return {
|
|
26
|
-
correlationId,
|
|
27
|
-
enterSpan: (name, callback) => tracing.enterSpan(name, callback)
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
//#endregion
|
|
32
|
-
export { isNativeTracingAvailable as n, getNativeTracerFromCtx as t };
|