@decocms/start 6.0.1 → 6.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/MIGRATION_TOOLING_PLAN.md +9 -0
- package/docs/observability.md +20 -10
- package/package.json +1 -1
- package/src/middleware/observability.test.ts +237 -0
- package/src/middleware/observability.ts +165 -8
- package/src/sdk/cachedLoader.ts +10 -7
- package/src/sdk/logger.test.ts +99 -0
- package/src/sdk/logger.ts +18 -7
- package/src/sdk/observability.ts +18 -0
- package/src/sdk/otel.ts +228 -38
- package/src/sdk/otelHttpTracer.test.ts +422 -0
- package/src/sdk/otelHttpTracer.ts +489 -0
- package/src/sdk/requestContext.ts +46 -0
- package/src/sdk/workerEntry.ts +138 -17
package/src/sdk/logger.test.ts
CHANGED
|
@@ -266,6 +266,105 @@ describe("serializeError", () => {
|
|
|
266
266
|
});
|
|
267
267
|
});
|
|
268
268
|
|
|
269
|
+
describe("request.id stamping (Phase 1, D-9)", () => {
|
|
270
|
+
afterEach(() => {
|
|
271
|
+
configureLogger(defaultLoggerAdapter);
|
|
272
|
+
setLogLevel("info");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("stamps request.id from RequestContext on every log line emitted inside a request scope", async () => {
|
|
276
|
+
const { RequestContext } = await import("./requestContext");
|
|
277
|
+
|
|
278
|
+
const seen: Array<Record<string, unknown> | undefined> = [];
|
|
279
|
+
configureLogger({
|
|
280
|
+
log(_l, _m, attrs) {
|
|
281
|
+
seen.push(attrs);
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const reqWithId = new Request("https://example.com/", {
|
|
286
|
+
headers: { "x-request-id": "client-supplied-uuid" },
|
|
287
|
+
});
|
|
288
|
+
await RequestContext.run(reqWithId, async () => {
|
|
289
|
+
logger.info("inside-scope", { custom: "yes" });
|
|
290
|
+
});
|
|
291
|
+
// Outside the scope, no request.id is stamped — the fast path stays
|
|
292
|
+
// fast (no allocation, no key) when RequestContext.requestId is null.
|
|
293
|
+
logger.info("outside-scope", { custom: "no" });
|
|
294
|
+
|
|
295
|
+
expect(seen[0]).toMatchObject({
|
|
296
|
+
"request.id": "client-supplied-uuid",
|
|
297
|
+
custom: "yes",
|
|
298
|
+
});
|
|
299
|
+
expect(seen[1]).toEqual({ custom: "no" });
|
|
300
|
+
expect(seen[1]).not.toHaveProperty("request.id");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("prefers caller-supplied request.id over the auto-generated one", async () => {
|
|
304
|
+
const { RequestContext } = await import("./requestContext");
|
|
305
|
+
|
|
306
|
+
const seen: Array<Record<string, unknown> | undefined> = [];
|
|
307
|
+
configureLogger({
|
|
308
|
+
log(_l, _m, attrs) {
|
|
309
|
+
seen.push(attrs);
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const req = new Request("https://example.com/", {
|
|
314
|
+
headers: { "x-request-id": "from-headers" },
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
await RequestContext.run(req, async () => {
|
|
318
|
+
// Caller can still override by passing the key directly in attrs.
|
|
319
|
+
logger.info("override", { "request.id": "explicit-from-caller" });
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
expect(seen[0]?.["request.id"]).toBe("explicit-from-caller");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("falls back to cf-ray when x-request-id is absent", async () => {
|
|
326
|
+
const { RequestContext } = await import("./requestContext");
|
|
327
|
+
|
|
328
|
+
const seen: Array<Record<string, unknown> | undefined> = [];
|
|
329
|
+
configureLogger({
|
|
330
|
+
log(_l, _m, attrs) {
|
|
331
|
+
seen.push(attrs);
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const req = new Request("https://example.com/", {
|
|
336
|
+
headers: { "cf-ray": "8a1b2c3d4e5f6a7b" },
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
await RequestContext.run(req, async () => {
|
|
340
|
+
logger.info("cf-ray-stamped");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
expect(seen[0]?.["request.id"]).toBe("8a1b2c3d4e5f6a7b");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("generates a fresh UUID when neither header is set", async () => {
|
|
347
|
+
const { RequestContext } = await import("./requestContext");
|
|
348
|
+
|
|
349
|
+
const seen: Array<Record<string, unknown> | undefined> = [];
|
|
350
|
+
configureLogger({
|
|
351
|
+
log(_l, _m, attrs) {
|
|
352
|
+
seen.push(attrs);
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const req = new Request("https://example.com/");
|
|
357
|
+
|
|
358
|
+
await RequestContext.run(req, async () => {
|
|
359
|
+
logger.info("uuid-stamped");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const stamped = seen[0]?.["request.id"];
|
|
363
|
+
expect(typeof stamped).toBe("string");
|
|
364
|
+
expect((stamped as string).length).toBeGreaterThan(8);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
269
368
|
describe("trace correlation", () => {
|
|
270
369
|
afterEach(() => {
|
|
271
370
|
configureLogger(defaultLoggerAdapter);
|
package/src/sdk/logger.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { getActiveSpan } from "./observability";
|
|
28
|
+
import { RequestContext } from "./requestContext";
|
|
28
29
|
|
|
29
30
|
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
30
31
|
|
|
@@ -252,20 +253,30 @@ function emit(level: LogLevel, msg: string, attrs?: Record<string, unknown>): vo
|
|
|
252
253
|
// to its trace in ClickStack/HyperDX. No active span → no-op; caller
|
|
253
254
|
// attrs always win so explicit `trace_id` overrides keep working.
|
|
254
255
|
const ctx = getActiveSpan()?.spanContext?.();
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
//
|
|
259
|
-
|
|
256
|
+
// Pull request.id from the AsyncLocalStorage-backed RequestContext so
|
|
257
|
+
// every log line in the request also carries the join key used by
|
|
258
|
+
// direct-POST metrics + tail-worker rows. Single read, no allocation
|
|
259
|
+
// when outside a request scope.
|
|
260
|
+
const requestId = RequestContext.requestId;
|
|
261
|
+
const requestAttrs: Record<string, unknown> | undefined =
|
|
262
|
+
ctx || requestId
|
|
263
|
+
? {
|
|
264
|
+
...(ctx ? { trace_id: ctx.traceId, span_id: ctx.spanId } : {}),
|
|
265
|
+
...(requestId ? { "request.id": requestId } : {}),
|
|
266
|
+
}
|
|
267
|
+
: undefined;
|
|
268
|
+
// Merge order: floor → trace / request context → caller attrs. Caller
|
|
269
|
+
// wins; the request-scoped context only overrides floor keys (which
|
|
270
|
+
// never set `trace_id` / `request.id` anyway).
|
|
260
271
|
const s = getState();
|
|
261
272
|
const hasFloor = Object.keys(s.attributeFloor).length > 0;
|
|
262
273
|
let merged: Record<string, unknown> | undefined;
|
|
263
|
-
if (!hasFloor && !
|
|
274
|
+
if (!hasFloor && !requestAttrs && !attrs) {
|
|
264
275
|
merged = undefined;
|
|
265
276
|
} else {
|
|
266
277
|
merged = {
|
|
267
278
|
...(hasFloor ? s.attributeFloor : {}),
|
|
268
|
-
...(
|
|
279
|
+
...(requestAttrs ?? {}),
|
|
269
280
|
...(attrs ?? {}),
|
|
270
281
|
};
|
|
271
282
|
}
|
package/src/sdk/observability.ts
CHANGED
|
@@ -52,6 +52,8 @@ export {
|
|
|
52
52
|
// Tracer / meter / request log primitives (re-exported from the middleware)
|
|
53
53
|
export {
|
|
54
54
|
type CacheDecision,
|
|
55
|
+
type CacheLayer,
|
|
56
|
+
type CommerceMetricLabels,
|
|
55
57
|
configureMeter,
|
|
56
58
|
configureTracer,
|
|
57
59
|
getActiveSpan,
|
|
@@ -62,16 +64,32 @@ export {
|
|
|
62
64
|
type MeterAdapter,
|
|
63
65
|
MetricNames,
|
|
64
66
|
recordCacheMetric,
|
|
67
|
+
recordCommerceMetric,
|
|
65
68
|
recordRequestMetric,
|
|
69
|
+
type RequestMetricLabels,
|
|
66
70
|
type RequestStore,
|
|
67
71
|
type Span,
|
|
68
72
|
setObservabilitySpanStore,
|
|
69
73
|
setSpanAttribute,
|
|
74
|
+
statusClassFor,
|
|
70
75
|
type TracerAdapter,
|
|
71
76
|
withTracing,
|
|
72
77
|
} from "../middleware/observability";
|
|
73
78
|
// Worker-entry wrapper + adapter wiring
|
|
74
79
|
export { instrumentWorker, type OtelOptions } from "./otel";
|
|
80
|
+
// Direct-POST OTLP trace exporter (Phase 3 / D-12). Exported for sites
|
|
81
|
+
// that need to wire a custom traces endpoint outside `instrumentWorker`,
|
|
82
|
+
// and for the audit tooling that asserts framework spans are flowing.
|
|
83
|
+
export {
|
|
84
|
+
createOtlpHttpTracerAdapter,
|
|
85
|
+
newSpanId,
|
|
86
|
+
newTraceId,
|
|
87
|
+
type OtlpHttpTracer,
|
|
88
|
+
type OtlpHttpTracerOptions,
|
|
89
|
+
parseTraceparent,
|
|
90
|
+
shouldSampleTrace,
|
|
91
|
+
type TraceContext,
|
|
92
|
+
} from "./otelHttpTracer";
|
|
75
93
|
// AE meter adapter + runtime env helpers (for tests / custom wiring)
|
|
76
94
|
export {
|
|
77
95
|
type AnalyticsEngineDataset,
|
package/src/sdk/otel.ts
CHANGED
|
@@ -61,10 +61,16 @@
|
|
|
61
61
|
import { SpanStatusCode, trace } from "@opentelemetry/api";
|
|
62
62
|
import { createCompositeLogger, createCompositeMeter } from "./composite";
|
|
63
63
|
import { configureLogger, defaultLoggerAdapter, setLoggerAttributeFloor } from "./logger";
|
|
64
|
-
import { configureMeter, configureTracer } from "./observability";
|
|
64
|
+
import { configureMeter, configureTracer, getActiveSpan } from "./observability";
|
|
65
65
|
import { createAnalyticsEngineMeterAdapter } from "./otelAdapters";
|
|
66
66
|
import { createOtlpHttpErrorLogAdapter, type OtlpHttpErrorLog } from "./otelHttpErrorLog";
|
|
67
67
|
import { createOtlpHttpMeterAdapter, type OtlpHttpMeter } from "./otelHttpMeter";
|
|
68
|
+
import {
|
|
69
|
+
createOtlpHttpTracerAdapter,
|
|
70
|
+
type OtlpHttpTracer,
|
|
71
|
+
type TraceContext,
|
|
72
|
+
} from "./otelHttpTracer";
|
|
73
|
+
import { RequestContext } from "./requestContext";
|
|
68
74
|
|
|
69
75
|
// ---------------------------------------------------------------------------
|
|
70
76
|
// Types
|
|
@@ -98,6 +104,36 @@ export interface OtelOptions {
|
|
|
98
104
|
otlpErrorLogsEndpointEnvVar?: string;
|
|
99
105
|
/** Set to `false` to disable the OTLP/HTTP error-log exporter explicitly. */
|
|
100
106
|
otlpErrorLogsEnabled?: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* Env var name holding the OTLP/HTTP traces endpoint used by the
|
|
109
|
+
* direct-POST span exporter. Defaults to `"DECO_OTEL_TRACES_ENDPOINT"`.
|
|
110
|
+
* When set (and `otlpTracesEnabled !== false`), framework `deco.*`
|
|
111
|
+
* spans created via `withTracing` are captured, sampled, and POSTed
|
|
112
|
+
* directly to this endpoint. Flushed alongside metrics via
|
|
113
|
+
* `ctx.waitUntil`.
|
|
114
|
+
*
|
|
115
|
+
* Without this endpoint configured, `withTracing` falls back to the
|
|
116
|
+
* `@opentelemetry/api` global tracer (the legacy CF auto-instrumentation
|
|
117
|
+
* path). The framework registers BOTH adapters when a traces endpoint
|
|
118
|
+
* is set so CF auto-spans stay intact AND framework spans get
|
|
119
|
+
* direct-POSTed to ClickHouse. See Phase 3 in
|
|
120
|
+
* `MIGRATION_TOOLING_PLAN.md`.
|
|
121
|
+
*/
|
|
122
|
+
otlpTracesEndpointEnvVar?: string;
|
|
123
|
+
/** Set to `false` to disable the OTLP/HTTP traces exporter explicitly. */
|
|
124
|
+
otlpTracesEnabled?: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* Head sampling rate for framework spans direct-POSTed via the OTLP
|
|
127
|
+
* traces endpoint. Default `0.01` matches the CF Destinations
|
|
128
|
+
* `traces.head_sampling_rate` recommendation. Decisions are consistent
|
|
129
|
+
* per trace (hash of `trace_id`), so child spans (`deco.cache.lookup`,
|
|
130
|
+
* `deco.cms.resolvePage`, ...) are kept iff their root
|
|
131
|
+
* `deco.http.request` span is kept. Set to `1` to capture every trace
|
|
132
|
+
* (preview / debug only — production cost grows linearly).
|
|
133
|
+
*/
|
|
134
|
+
otlpTracesSamplingRate?: number;
|
|
135
|
+
/** Test seam — replace the global `fetch` used by the traces exporter. */
|
|
136
|
+
otlpTracesFetchImpl?: typeof fetch;
|
|
101
137
|
/**
|
|
102
138
|
* Version of `@decocms/start` to advertise as `deco.runtime.version`
|
|
103
139
|
* on every span and every log line. Falls back to a build-time constant;
|
|
@@ -150,6 +186,37 @@ let otlpMeter: OtlpHttpMeter | null = null;
|
|
|
150
186
|
*/
|
|
151
187
|
let otlpErrorLog: OtlpHttpErrorLog | null = null;
|
|
152
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Module-level handle to the OTLP/HTTP trace exporter — installed by
|
|
191
|
+
* `bootObservability` when `DECO_OTEL_TRACES_ENDPOINT` is set on `env`.
|
|
192
|
+
* Flushed alongside the metrics + error-log exporters via
|
|
193
|
+
* `ctx.waitUntil(...)` at the end of every request.
|
|
194
|
+
*/
|
|
195
|
+
let otlpTracer: OtlpHttpTracer | null = null;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Per-request inbound W3C trace context, parsed from the `traceparent`
|
|
199
|
+
* header at request entry. Read by the OTLP trace exporter when it
|
|
200
|
+
* creates a root span so we honor remote parents and the `sampled`
|
|
201
|
+
* flag. Stored on a request-scoped slot (via `RequestContext.bag`) so
|
|
202
|
+
* concurrent requests in the same isolate don't trample each other.
|
|
203
|
+
*/
|
|
204
|
+
const TRACE_CTX_BAG_KEY = "deco.observability.traceContext.v1";
|
|
205
|
+
|
|
206
|
+
function getRequestTraceContext(): TraceContext | null {
|
|
207
|
+
return RequestContext.getBag<TraceContext>(TRACE_CTX_BAG_KEY) ?? null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Public entry point used by `workerEntry.ts` to stash the parsed
|
|
212
|
+
* traceparent for the OTLP tracer to consume. Exported (not just
|
|
213
|
+
* module-local) because the parser lives in `otelHttpTracer.ts` and
|
|
214
|
+
* the call site is `workerEntry.ts`.
|
|
215
|
+
*/
|
|
216
|
+
export function _setRequestTraceContext(ctx: TraceContext | null): void {
|
|
217
|
+
if (ctx) RequestContext.setBag(TRACE_CTX_BAG_KEY, ctx);
|
|
218
|
+
}
|
|
219
|
+
|
|
153
220
|
/**
|
|
154
221
|
* Per-span attribute floor — stamped on every span we create via
|
|
155
222
|
* `configureTracer().startSpan(...)`. CF's trace export emits its own
|
|
@@ -183,38 +250,13 @@ export function instrumentWorker(
|
|
|
183
250
|
handler: WorkerHandler,
|
|
184
251
|
options: OtelOptions | ((env: Record<string, unknown>) => OtelOptions) = {},
|
|
185
252
|
): WorkerHandler {
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
// tracing the global tracer is a no-op proxy and the spans simply drop
|
|
194
|
-
// — same outcome as before, no error.
|
|
195
|
-
configureTracer({
|
|
196
|
-
startSpan: (name, attrs) => {
|
|
197
|
-
const merged = { ...spanAttributeFloor, ...(attrs ?? {}) };
|
|
198
|
-
const span = trace.getTracer("@decocms/start").startSpan(name, { attributes: merged });
|
|
199
|
-
return {
|
|
200
|
-
end: () => span.end(),
|
|
201
|
-
setError: (error) => {
|
|
202
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
203
|
-
if (error instanceof Error) span.recordException(error);
|
|
204
|
-
span.setStatus({ code: SpanStatusCode.ERROR, message });
|
|
205
|
-
},
|
|
206
|
-
setAttribute: (k, v) => span.setAttribute(k, v),
|
|
207
|
-
spanContext: () => {
|
|
208
|
-
const ctx = span.spanContext();
|
|
209
|
-
return {
|
|
210
|
-
traceId: ctx.traceId,
|
|
211
|
-
spanId: ctx.spanId,
|
|
212
|
-
traceFlags: ctx.traceFlags,
|
|
213
|
-
};
|
|
214
|
-
},
|
|
215
|
-
};
|
|
216
|
-
},
|
|
217
|
-
});
|
|
253
|
+
// Default tracer bridge — delegates to `@opentelemetry/api` global. When
|
|
254
|
+
// `bootObservability` discovers `DECO_OTEL_TRACES_ENDPOINT`, it composes
|
|
255
|
+
// this bridge with the direct-POST OTLP tracer so framework spans flow to
|
|
256
|
+
// BOTH the CF dashboard AND ClickHouse (the bridge stays a no-op when CF
|
|
257
|
+
// tracing isn't configured, which is the common case today). See
|
|
258
|
+
// `configureTracerStack` below.
|
|
259
|
+
configureTracer(buildOtelApiTracer());
|
|
218
260
|
|
|
219
261
|
return {
|
|
220
262
|
async fetch(request, env, ctx) {
|
|
@@ -227,11 +269,11 @@ export function instrumentWorker(
|
|
|
227
269
|
try {
|
|
228
270
|
return await handler.fetch(request, env, ctx);
|
|
229
271
|
} finally {
|
|
230
|
-
// Drain the OTLP metrics + error-log buffers via
|
|
231
|
-
// so
|
|
232
|
-
//
|
|
233
|
-
// the network only fires when the cooldown elapses or
|
|
234
|
-
// buffer fills.
|
|
272
|
+
// Drain the OTLP metrics + error-log + traces buffers via
|
|
273
|
+
// ctx.waitUntil so no POST blocks the response. Each exporter
|
|
274
|
+
// throttles itself per isolate — calling on every request is
|
|
275
|
+
// cheap; the network only fires when the cooldown elapses or
|
|
276
|
+
// the buffer fills.
|
|
235
277
|
if (otlpMeter) {
|
|
236
278
|
try {
|
|
237
279
|
ctx.waitUntil(otlpMeter.flush());
|
|
@@ -246,11 +288,122 @@ export function instrumentWorker(
|
|
|
246
288
|
/* swallow */
|
|
247
289
|
}
|
|
248
290
|
}
|
|
291
|
+
if (otlpTracer) {
|
|
292
|
+
try {
|
|
293
|
+
ctx.waitUntil(otlpTracer.flush());
|
|
294
|
+
} catch {
|
|
295
|
+
/* swallow */
|
|
296
|
+
}
|
|
297
|
+
}
|
|
249
298
|
}
|
|
250
299
|
},
|
|
251
300
|
};
|
|
252
301
|
}
|
|
253
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Build the legacy `@opentelemetry/api` global-tracer bridge. Stays a
|
|
305
|
+
* no-op when no global TracerProvider is registered — same outcome as
|
|
306
|
+
* the historical configuration.
|
|
307
|
+
*/
|
|
308
|
+
function buildOtelApiTracer(): import("../middleware/observability").TracerAdapter {
|
|
309
|
+
return {
|
|
310
|
+
startSpan: (name, attrs) => {
|
|
311
|
+
const merged = { ...spanAttributeFloor, ...(attrs ?? {}) };
|
|
312
|
+
const span = trace.getTracer("@decocms/start").startSpan(name, { attributes: merged });
|
|
313
|
+
return {
|
|
314
|
+
end: () => span.end(),
|
|
315
|
+
setError: (error) => {
|
|
316
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
317
|
+
if (error instanceof Error) span.recordException(error);
|
|
318
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message });
|
|
319
|
+
},
|
|
320
|
+
setAttribute: (k, v) => span.setAttribute(k, v),
|
|
321
|
+
spanContext: () => {
|
|
322
|
+
const ctx = span.spanContext();
|
|
323
|
+
return {
|
|
324
|
+
traceId: ctx.traceId,
|
|
325
|
+
spanId: ctx.spanId,
|
|
326
|
+
traceFlags: ctx.traceFlags,
|
|
327
|
+
};
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Wire the framework tracer. When the OTLP traces endpoint is configured,
|
|
336
|
+
* compose the direct-POST tracer ALONGSIDE the `@opentelemetry/api` bridge
|
|
337
|
+
* via a fanout adapter so:
|
|
338
|
+
* 1. `withTracing` calls feed BOTH adapters.
|
|
339
|
+
* 2. A child span's `spanContext()` reports the direct-POST span's IDs
|
|
340
|
+
* (the bridge is best-effort — if CF tracing isn't installed those
|
|
341
|
+
* IDs are zeros anyway).
|
|
342
|
+
*
|
|
343
|
+
* When no traces endpoint is configured, fall back to the bridge alone —
|
|
344
|
+
* preserves the legacy behavior for sites that haven't bumped wrangler.
|
|
345
|
+
*/
|
|
346
|
+
function configureTracerStack(otlpAdapter: OtlpHttpTracer | null): void {
|
|
347
|
+
const bridge = buildOtelApiTracer();
|
|
348
|
+
if (!otlpAdapter) {
|
|
349
|
+
configureTracer(bridge);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
// Compose. The OTLP adapter is the "primary" (its IDs win for
|
|
353
|
+
// `spanContext()` because callers downstream use them for trace
|
|
354
|
+
// propagation). The bridge is best-effort — fed the same name/attrs
|
|
355
|
+
// so CF Workers Observability still sees the spans if that channel
|
|
356
|
+
// is enabled in wrangler.jsonc.
|
|
357
|
+
configureTracer({
|
|
358
|
+
startSpan(name, attrs) {
|
|
359
|
+
const merged = { ...spanAttributeFloor, ...(attrs ?? {}) };
|
|
360
|
+
const primary = otlpAdapter.startSpan(name, merged);
|
|
361
|
+
const secondary = bridge.startSpan(name, merged);
|
|
362
|
+
return {
|
|
363
|
+
end(): void {
|
|
364
|
+
try {
|
|
365
|
+
primary.end();
|
|
366
|
+
} finally {
|
|
367
|
+
try {
|
|
368
|
+
secondary.end();
|
|
369
|
+
} catch {
|
|
370
|
+
/* swallow */
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
setError(error: unknown): void {
|
|
375
|
+
try {
|
|
376
|
+
primary.setError?.(error);
|
|
377
|
+
} finally {
|
|
378
|
+
try {
|
|
379
|
+
secondary.setError?.(error);
|
|
380
|
+
} catch {
|
|
381
|
+
/* swallow */
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
setAttribute(key: string, value: string | number | boolean): void {
|
|
386
|
+
primary.setAttribute?.(key, value);
|
|
387
|
+
try {
|
|
388
|
+
secondary.setAttribute?.(key, value);
|
|
389
|
+
} catch {
|
|
390
|
+
/* swallow */
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
spanContext() {
|
|
394
|
+
// The OTLP adapter owns the canonical IDs — those are the IDs
|
|
395
|
+
// we propagate downstream via `traceparent` headers.
|
|
396
|
+
return primary.spanContext?.() ?? secondary.spanContext?.() ?? {
|
|
397
|
+
traceId: "",
|
|
398
|
+
spanId: "",
|
|
399
|
+
traceFlags: 0,
|
|
400
|
+
};
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
254
407
|
// ---------------------------------------------------------------------------
|
|
255
408
|
// Boot — wires the loggers/meters once (per worker isolate)
|
|
256
409
|
// ---------------------------------------------------------------------------
|
|
@@ -391,6 +544,41 @@ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): voi
|
|
|
391
544
|
// composite becomes a 0-element no-op via createCompositeMeter's filter.
|
|
392
545
|
configureMeter(composedMeter);
|
|
393
546
|
|
|
547
|
+
// Traces — direct-POST exporter for framework `deco.*` spans. Without
|
|
548
|
+
// this, `withTracing` delegates to the no-op `@opentelemetry/api`
|
|
549
|
+
// global tracer and every framework span silently disappears (the
|
|
550
|
+
// Phase 3 gap documented in `MIGRATION_TOOLING_PLAN.md`). Same
|
|
551
|
+
// transport pattern as metrics/error-logs — buffered, sampled by
|
|
552
|
+
// trace-id hash, flushed via `ctx.waitUntil`.
|
|
553
|
+
const otlpTracesEnvVar = opts.otlpTracesEndpointEnvVar ?? "DECO_OTEL_TRACES_ENDPOINT";
|
|
554
|
+
const otlpTracesEndpoint = (env[otlpTracesEnvVar] as string | undefined) ?? "";
|
|
555
|
+
const otlpTracesEnabled =
|
|
556
|
+
opts.otlpTracesEnabled !== false && otlpTracesEndpoint.length > 0;
|
|
557
|
+
if (otlpTracesEnabled) {
|
|
558
|
+
otlpTracer = createOtlpHttpTracerAdapter({
|
|
559
|
+
endpoint: otlpTracesEndpoint,
|
|
560
|
+
resourceAttributes: floor,
|
|
561
|
+
scopeVersion: decoRuntimeVersion,
|
|
562
|
+
headSamplingRate: opts.otlpTracesSamplingRate ?? 0.01,
|
|
563
|
+
fetchImpl: opts.otlpTracesFetchImpl,
|
|
564
|
+
getActiveSpanForParent: () => getActiveSpan(),
|
|
565
|
+
getRequestTraceContext,
|
|
566
|
+
onError: (kind, err) => {
|
|
567
|
+
defaultLoggerAdapter.log("warn", "otlp traces exporter", {
|
|
568
|
+
kind,
|
|
569
|
+
error: err instanceof Error ? err.message : String(err),
|
|
570
|
+
});
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
} else {
|
|
574
|
+
otlpTracer = null;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Wire the tracer stack — composes the OTLP direct-POST adapter (when
|
|
578
|
+
// configured) with the @opentelemetry/api bridge. See
|
|
579
|
+
// `configureTracerStack` for the fanout semantics.
|
|
580
|
+
configureTracerStack(otlpTracer);
|
|
581
|
+
|
|
394
582
|
booted = true;
|
|
395
583
|
|
|
396
584
|
// Single boot-time breadcrumb so operators can confirm the wiring at a
|
|
@@ -400,6 +588,7 @@ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): voi
|
|
|
400
588
|
analyticsEngine: aeEnabled,
|
|
401
589
|
otlpMetrics: otlpEnabled,
|
|
402
590
|
otlpErrorLogs: otlpErrorLogsEnabled,
|
|
591
|
+
otlpTraces: otlpTracesEnabled,
|
|
403
592
|
runtimeVersion: decoRuntimeVersion,
|
|
404
593
|
deploymentEnvironment,
|
|
405
594
|
...(serviceVersion ? { serviceVersion } : {}),
|
|
@@ -415,6 +604,7 @@ export function _resetBootStateForTests(): void {
|
|
|
415
604
|
spanAttributeFloor = {};
|
|
416
605
|
otlpMeter = null;
|
|
417
606
|
otlpErrorLog = null;
|
|
607
|
+
otlpTracer = null;
|
|
418
608
|
setLoggerAttributeFloor({});
|
|
419
609
|
}
|
|
420
610
|
|