From 5170ce708c606283e8a30d273950f1a21c7eddc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20Ve=C4=8Derek?= Date: Tue, 2 Apr 2024 03:57:56 +0200 Subject: [PATCH] Add support for W3C Trace Context propagation (#2445) Co-authored-by: Tim --- .changeset/chatty-wombats-bow.md | 5 + .changeset/great-insects-hug.md | 5 + packages/effect/src/internal/tracer.ts | 7 +- packages/platform/src/Http/IncomingMessage.ts | 73 +----------- packages/platform/src/Http/TraceContext.ts | 109 ++++++++++++++++++ packages/platform/src/internal/http/client.ts | 17 +-- .../platform/src/internal/http/middleware.ts | 95 ++++++++------- scripts/docs.mjs | 2 +- 8 files changed, 178 insertions(+), 135 deletions(-) create mode 100644 .changeset/chatty-wombats-bow.md create mode 100644 .changeset/great-insects-hug.md create mode 100644 packages/platform/src/Http/TraceContext.ts diff --git a/.changeset/chatty-wombats-bow.md b/.changeset/chatty-wombats-bow.md new file mode 100644 index 0000000000..74c472265a --- /dev/null +++ b/.changeset/chatty-wombats-bow.md @@ -0,0 +1,5 @@ +--- +"@effect/platform": patch +--- + +Add support for W3C Trace Context propagation diff --git a/.changeset/great-insects-hug.md b/.changeset/great-insects-hug.md new file mode 100644 index 0000000000..f7ba87f4f5 --- /dev/null +++ b/.changeset/great-insects-hug.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +generate proper trace ids in default effect Tracer diff --git a/packages/effect/src/internal/tracer.ts b/packages/effect/src/internal/tracer.ts index d4abfafa5b..268e9cee63 100644 --- a/packages/effect/src/internal/tracer.ts +++ b/packages/effect/src/internal/tracer.ts @@ -21,8 +21,8 @@ export const tracerTag = Context.GenericTag("effect/Tracer") /** @internal */ export const spanTag = Context.GenericTag("effect/ParentSpan") -const randomString = (function() { - const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" +const randomHexString = (function() { + const characters = "abcdef0123456789" const charactersLength = characters.length return function(length: number) { let result = "" @@ -56,7 +56,8 @@ export class NativeSpan implements Tracer.Span { startTime } this.attributes = new Map() - this.spanId = `span${randomString(16)}` + this.traceId = parent._tag === "Some" ? parent.value.traceId : randomHexString(32) + this.spanId = randomHexString(16) } end(endTime: bigint, exit: Exit.Exit): void { diff --git a/packages/platform/src/Http/IncomingMessage.ts b/packages/platform/src/Http/IncomingMessage.ts index 7d93f7aa1e..d56f8884db 100644 --- a/packages/platform/src/Http/IncomingMessage.ts +++ b/packages/platform/src/Http/IncomingMessage.ts @@ -2,17 +2,15 @@ * @since 1.0.0 */ import type { ParseOptions } from "@effect/schema/AST" -import * as ParseResult from "@effect/schema/ParseResult" +import type * as ParseResult from "@effect/schema/ParseResult" import * as Schema from "@effect/schema/Schema" import * as Effect from "effect/Effect" import * as FiberRef from "effect/FiberRef" -import { dual, flow } from "effect/Function" +import { dual } from "effect/Function" import * as Global from "effect/GlobalValue" import type { Inspectable } from "effect/Inspectable" import * as Option from "effect/Option" import type * as Stream from "effect/Stream" -import * as Tracer from "effect/Tracer" -import type { ExternalSpan } from "effect/Tracer" import * as FileSystem from "../FileSystem.js" import type * as Headers from "./Headers.js" import type * as UrlParams from "./UrlParams.js" @@ -112,73 +110,6 @@ export const schemaHeadersEffect = return (effect: Effect.Effect, E2, R2>) => Effect.scoped(Effect.flatMap(effect, decode)) } -const SpanSchema = Schema.struct({ - traceId: Schema.string, - spanId: Schema.string, - parentSpanId: Schema.union(Schema.string, Schema.undefined), - sampled: Schema.boolean -}) - -/** - * @since 1.0.0 - * @category schema - */ -export const schemaExternalSpan: ( - self: IncomingMessage -) => Effect.Effect = flow( - schemaHeaders(Schema.union( - Schema.transformOrFail( - Schema.struct({ - b3: Schema.NonEmpty - }), - SpanSchema, - (input, _, ast) => { - const parts = input.b3.split("-") - if (parts.length >= 2) { - return ParseResult.succeed( - { - traceId: parts[0], - spanId: parts[1], - sampled: parts[2] ? parts[2] === "1" : true, - parentSpanId: parts[3] - } as const - ) - } - return ParseResult.fail(new ParseResult.Type(ast, input)) - }, - (_) => ParseResult.succeed({ b3: "" } as const) - ), - Schema.transform( - Schema.struct({ - "x-b3-traceid": Schema.NonEmpty, - "x-b3-spanid": Schema.NonEmpty, - "x-b3-parentspanid": Schema.optional(Schema.NonEmpty), - "x-b3-sampled": Schema.optional(Schema.NonEmpty, { default: () => "1" }) - }), - SpanSchema, - (_) => ({ - traceId: _["x-b3-traceid"], - spanId: _["x-b3-spanid"], - parentSpanId: _["x-b3-parentspanid"], - sampled: _["x-b3-sampled"] === "1" - } as const), - (_) => ({ - "x-b3-traceid": _.traceId, - "x-b3-spanid": _.spanId, - "x-b3-parentspanid": _.parentSpanId, - "x-b3-sampled": _.sampled ? "1" : "0" - } as const) - ) - )), - Effect.map((_): ExternalSpan => - Tracer.externalSpan({ - traceId: _.traceId, - spanId: _.spanId, - sampled: _.sampled - }) - ) -) - /** * @since 1.0.0 * @category fiber refs diff --git a/packages/platform/src/Http/TraceContext.ts b/packages/platform/src/Http/TraceContext.ts new file mode 100644 index 0000000000..c041d9f19f --- /dev/null +++ b/packages/platform/src/Http/TraceContext.ts @@ -0,0 +1,109 @@ +/** + * @since 1.0.0 + */ +import * as Option from "effect/Option" +import * as Tracer from "effect/Tracer" +import * as Headers from "./Headers.js" + +/** + * @since 1.0.0 + * @category models + */ +export interface FromHeaders { + (headers: Headers.Headers): Option.Option +} + +/** + * @since 1.0.0 + * @category encoding + */ +export const toHeaders = (span: Tracer.Span): Headers.Headers => + Headers.unsafeFromRecord({ + b3: `${span.traceId}-${span.spanId}-${span.sampled ? "1" : "0"}${ + span.parent._tag === "Some" ? `-${span.parent.value.spanId}` : "" + }`, + traceparent: `00-${span.traceId}-${span.spanId}-${span.sampled ? "01" : "00"}` + }) + +/** + * @since 1.0.0 + * @category decoding + */ +export const fromHeaders = (headers: Headers.Headers): Option.Option => { + let span = w3c(headers) + if (span._tag === "Some") { + return span + } + span = b3(headers) + if (span._tag === "Some") { + return span + } + return xb3(headers) +} + +/** + * @since 1.0.0 + * @category decoding + */ +export const b3: FromHeaders = (headers) => { + if (!("b3" in headers)) { + return Option.none() + } + const parts = headers["b3"].split("-") + if (parts.length < 2) { + return Option.none() + } + return Option.some(Tracer.externalSpan({ + traceId: parts[0], + spanId: parts[1], + sampled: parts[2] ? parts[2] === "1" : true + })) +} + +/** + * @since 1.0.0 + * @category decoding + */ +export const xb3: FromHeaders = (headers) => { + if (!(headers["x-b3-traceid"]) || !(headers["x-b3-spanid"])) { + return Option.none() + } + return Option.some(Tracer.externalSpan({ + traceId: headers["x-b3-traceid"], + spanId: headers["x-b3-spanid"], + sampled: headers["x-b3-sampled"] ? headers["x-b3-sampled"] === "1" : true + })) +} + +const w3cTraceId = /^[0-9a-f]{32}$/gi +const w3cSpanId = /^[0-9a-f]{16}$/gi + +/** + * @since 1.0.0 + * @category decoding + */ +export const w3c: FromHeaders = (headers) => { + if (!(headers["traceparent"])) { + return Option.none() + } + const parts = headers["traceparent"].split("-") + if (parts.length !== 4) { + return Option.none() + } + const [version, traceId, spanId, flags] = parts + switch (version) { + case "00": { + if (w3cTraceId.test(traceId) === false || w3cSpanId.test(spanId) === false) { + return Option.none() + } + return Option.some(Tracer.externalSpan({ + traceId, + spanId, + sampled: (parseInt(flags, 16) & 1) === 1 + })) + } + default: { + return Option.none() + } + } +} diff --git a/packages/platform/src/internal/http/client.ts b/packages/platform/src/internal/http/client.ts index 99f4592f39..f06fd5ac6f 100644 --- a/packages/platform/src/internal/http/client.ts +++ b/packages/platform/src/internal/http/client.ts @@ -14,7 +14,6 @@ import * as Ref from "effect/Ref" import type * as Schedule from "effect/Schedule" import type * as Scope from "effect/Scope" import * as Stream from "effect/Stream" -import type * as Tracer from "effect/Tracer" import type * as Body from "../../Http/Body.js" import type * as Client from "../../Http/Client.js" import * as Error from "../../Http/ClientError.js" @@ -22,6 +21,7 @@ import type * as ClientRequest from "../../Http/ClientRequest.js" import type * as ClientResponse from "../../Http/ClientResponse.js" import * as Cookies from "../../Http/Cookies.js" import * as Method from "../../Http/Method.js" +import * as TraceContext from "../../Http/TraceContext.js" import * as UrlParams from "../../Http/UrlParams.js" import * as internalBody from "./body.js" import * as internalRequest from "./clientRequest.js" @@ -77,15 +77,6 @@ export const make = ( return client as any } -const addB3Header = (req: ClientRequest.ClientRequest, span: Tracer.Span) => - internalRequest.setHeader( - req, - "b3", - `${span.traceId}-${span.spanId}-${span.sampled ? "1" : "0"}${ - span.parent._tag === "Some" ? `-${span.parent.value.spanId}` : "" - }` - ) - /** @internal */ export const makeDefault = ( f: ( @@ -109,7 +100,11 @@ export const makeDefault = ( "http.url": request.url } }, - (span) => Effect.withParentSpan(f(addB3Header(request, span), fiber), span) + (span) => + Effect.withParentSpan( + f(internalRequest.setHeaders(request, TraceContext.toHeaders(span)), fiber), + span + ) ) })), Effect.succeed as Client.Client.Preprocess diff --git a/packages/platform/src/internal/http/middleware.ts b/packages/platform/src/internal/http/middleware.ts index 0d81b26214..0b8d9d0359 100644 --- a/packages/platform/src/internal/http/middleware.ts +++ b/packages/platform/src/internal/http/middleware.ts @@ -1,14 +1,16 @@ import * as Cause from "effect/Cause" +import * as Context from "effect/Context" import * as Effect from "effect/Effect" import * as FiberRef from "effect/FiberRef" import { constFalse, dual } from "effect/Function" import { globalValue } from "effect/GlobalValue" +import * as Option from "effect/Option" import type * as Predicate from "effect/Predicate" import * as Headers from "../../Http/Headers.js" -import * as IncomingMessage from "../../Http/IncomingMessage.js" import type * as Middleware from "../../Http/Middleware.js" import * as ServerError from "../../Http/ServerError.js" import * as ServerRequest from "../../Http/ServerRequest.js" +import * as TraceContext from "../../Http/TraceContext.js" /** @internal */ export const make = (middleware: M): M => middleware @@ -46,37 +48,33 @@ export const withTracerDisabledWhen = dual< /** @internal */ export const logger = make((httpApp) => { let counter = 0 - return Effect.flatMap( - ServerRequest.ServerRequest, - (request) => - Effect.withLogSpan( - Effect.onExit(httpApp, (exit) => - Effect.flatMap( - FiberRef.get(loggerDisabled), - (disabled) => { - if (disabled) { - return Effect.unit - } - return exit._tag === "Failure" ? - Effect.annotateLogs(Effect.log(exit.cause), { - "http.method": request.method, - "http.url": request.url, - "http.status": Cause.isInterruptedOnly(exit.cause) - ? ServerError.isClientAbortCause(exit.cause) - ? 499 - : 503 - : 500 - }) : - Effect.annotateLogs(Effect.log("Sent HTTP response"), { - "http.method": request.method, - "http.url": request.url, - "http.status": exit.value.status - }) - } - )), - `http.span.${++counter}` - ) - ) + return Effect.withFiberRuntime((fiber) => { + const context = fiber.getFiberRef(FiberRef.currentContext) + const request = Context.unsafeGet(context, ServerRequest.ServerRequest) + return Effect.withLogSpan( + Effect.onExit(httpApp, (exit) => { + if (fiber.getFiberRef(loggerDisabled)) { + return Effect.unit + } + return exit._tag === "Failure" ? + Effect.annotateLogs(Effect.log(exit.cause), { + "http.method": request.method, + "http.url": request.url, + "http.status": Cause.isInterruptedOnly(exit.cause) + ? ServerError.isClientAbortCause(exit.cause) + ? 499 + : 503 + : 500 + }) : + Effect.annotateLogs(Effect.log("Sent HTTP response"), { + "http.method": request.method, + "http.url": request.url, + "http.status": exit.value.status + }) + }), + `http.span.${++counter}` + ) + }) }) /** @internal */ @@ -85,23 +83,22 @@ export const tracer = make((httpApp) => { httpApp, (response) => Effect.annotateCurrentSpan("http.status", response.status) ) - return Effect.flatMap( - Effect.zip(ServerRequest.ServerRequest, FiberRef.get(currentTracerDisabledWhen)), - ([request, disabledWhen]) => - Effect.flatMap( - request.headers["x-b3-traceid"] || request.headers["b3"] ? - Effect.orElseSucceed(IncomingMessage.schemaExternalSpan(request), () => undefined) : - Effect.succeed(undefined), - (parent) => - disabledWhen(request) ? - httpApp : - Effect.withSpan( - appWithStatus, - `http ${request.method}`, - { attributes: { "http.method": request.method, "http.url": request.url }, parent } - ) - ) - ) + return Effect.withFiberRuntime((fiber) => { + const context = fiber.getFiberRef(FiberRef.currentContext) + const request = Context.unsafeGet(context, ServerRequest.ServerRequest) + const disabled = fiber.getFiberRef(currentTracerDisabledWhen)(request) + if (disabled) { + return httpApp + } + return Effect.withSpan( + appWithStatus, + `http.server ${request.method}`, + { + attributes: { "http.method": request.method, "http.url": request.url }, + parent: Option.getOrUndefined(TraceContext.fromHeaders(request.headers)) + } + ) + }) }) /** @internal */ diff --git a/scripts/docs.mjs b/scripts/docs.mjs index 2e90ff543c..efeb2bee38 100644 --- a/scripts/docs.mjs +++ b/scripts/docs.mjs @@ -25,7 +25,7 @@ function copyFiles(pkg) { if (file.isDirectory()) { Fs.mkdirSync(destPath, { recursive: true }) - handleFiles(file.name, Fs.readdirSync(path, { withFileTypes: true })) + handleFiles(Path.join(root, file.name), Fs.readdirSync(path, { withFileTypes: true })) continue }