| author | |
| committer | |
| log | 04a1ae77e6b120c6d9b6a51768a1e411afe970be |
| tree | cfa347b870051554c66e06de83d5f4506e55d7c8 |
| parent | 6de4b47ecb5fc1cee11a3f3ef1b8279c35764cf6 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>13 files changed, 1010 insertions(+), 41 deletions(-)
apps/server/src/http.ts+75-2| ... | @@ -1,6 +1,15 @@ | ... | @@ -1,6 +1,15 @@ |
| 1 | import Mime from "@effect/platform-node/Mime"; | 1 | import Mime from "@effect/platform-node/Mime"; |
| 2 | import { Effect, FileSystem, Option, Path } from "effect"; | 2 | import { Data, Effect, FileSystem, Layer, Option, Path } from "effect"; |
| 3 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | 3 | import { cast } from "effect/Function"; |
| 4 | import { | ||
| 5 | HttpBody, | ||
| 6 | HttpClient, | ||
| 7 | HttpClientResponse, | ||
| 8 | HttpRouter, | ||
| 9 | HttpServerResponse, | ||
| 10 | HttpServerRequest, | ||
| 11 | } from "effect/unstable/http"; | ||
| 12 | import { OtlpTracer } from "effect/unstable/observability"; | ||
| 4 | 13 | ||
| 5 | import { | 14 | import { |
| 6 | ATTACHMENTS_ROUTE_PREFIX, | 15 | ATTACHMENTS_ROUTE_PREFIX, |
| ... | @@ -9,10 +18,74 @@ import { | ... | @@ -9,10 +18,74 @@ import { |
| 9 | } from "./attachmentPaths"; | 18 | } from "./attachmentPaths"; |
| 10 | import { resolveAttachmentPathById } from "./attachmentStore"; | 19 | import { resolveAttachmentPathById } from "./attachmentStore"; |
| 11 | import { ServerConfig } from "./config"; | 20 | import { ServerConfig } from "./config"; |
| 21 | import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts"; | ||
| 22 | import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; | ||
| 12 | import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver"; | 23 | import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver"; |
| 13 | 24 | ||
| 14 | const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; | 25 | const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; |
| 15 | const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`; | 26 | const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`; |
| 27 | const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; | ||
| 28 | |||
| 29 | class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{ | ||
| 30 | readonly cause: unknown; | ||
| 31 | readonly bodyJson: OtlpTracer.TraceData; | ||
| 32 | }> {} | ||
| 33 | |||
| 34 | export const otlpTracesProxyRouteLayer = HttpRouter.add( | ||
| 35 | "POST", | ||
| 36 | OTLP_TRACES_PROXY_PATH, | ||
| 37 | Effect.gen(function* () { | ||
| 38 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 39 | const config = yield* ServerConfig; | ||
| 40 | const otlpTracesUrl = config.otlpTracesUrl; | ||
| 41 | const browserTraceCollector = yield* BrowserTraceCollector; | ||
| 42 | const httpClient = yield* HttpClient.HttpClient; | ||
| 43 | const bodyJson = cast<unknown, OtlpTracer.TraceData>(yield* request.json); | ||
| 44 | |||
| 45 | yield* Effect.try({ | ||
| 46 | try: () => decodeOtlpTraceRecords(bodyJson), | ||
| 47 | catch: (cause) => new DecodeOtlpTraceRecordsError({ cause, bodyJson }), | ||
| 48 | }).pipe( | ||
| 49 | Effect.flatMap((records) => browserTraceCollector.record(records)), | ||
| 50 | Effect.catch((cause) => | ||
| 51 | Effect.logWarning("Failed to decode browser OTLP traces", { | ||
| 52 | cause, | ||
| 53 | bodyJson, | ||
| 54 | }), | ||
| 55 | ), | ||
| 56 | ); | ||
| 57 | |||
| 58 | if (otlpTracesUrl === undefined) { | ||
| 59 | return HttpServerResponse.empty({ status: 204 }); | ||
| 60 | } | ||
| 61 | |||
| 62 | return yield* httpClient | ||
| 63 | .post(otlpTracesUrl, { | ||
| 64 | body: HttpBody.jsonUnsafe(bodyJson), | ||
| 65 | }) | ||
| 66 | .pipe( | ||
| 67 | Effect.flatMap(HttpClientResponse.filterStatusOk), | ||
| 68 | Effect.as(HttpServerResponse.empty({ status: 204 })), | ||
| 69 | Effect.tapError((cause) => | ||
| 70 | Effect.logWarning("Failed to export browser OTLP traces", { | ||
| 71 | cause, | ||
| 72 | otlpTracesUrl, | ||
| 73 | }), | ||
| 74 | ), | ||
| 75 | Effect.catch(() => | ||
| 76 | Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })), | ||
| 77 | ), | ||
| 78 | ); | ||
| 79 | }), | ||
| 80 | ).pipe( | ||
| 81 | Layer.provide( | ||
| 82 | HttpRouter.cors({ | ||
| 83 | allowedMethods: ["POST", "OPTIONS"], | ||
| 84 | allowedHeaders: ["content-type"], | ||
| 85 | maxAge: 600, | ||
| 86 | }), | ||
| 87 | ), | ||
| 88 | ); | ||
| 16 | 89 | ||
| 17 | export const attachmentsRouteLayer = HttpRouter.add( | 90 | export const attachmentsRouteLayer = HttpRouter.add( |
| 18 | "GET", | 91 | "GET", |
apps/server/src/observability/Layers/Observability.ts+23-3| ... | @@ -4,6 +4,8 @@ import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/obse | ... | @@ -4,6 +4,8 @@ import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/obse |
| 4 | import { ServerConfig } from "../../config.ts"; | 4 | import { ServerConfig } from "../../config.ts"; |
| 5 | import { ServerLoggerLive } from "../../serverLogger.ts"; | 5 | import { ServerLoggerLive } from "../../serverLogger.ts"; |
| 6 | import { makeLocalFileTracer } from "../LocalFileTracer.ts"; | 6 | import { makeLocalFileTracer } from "../LocalFileTracer.ts"; |
| 7 | import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts"; | ||
| 8 | import { makeTraceSink } from "../TraceSink.ts"; | ||
| 7 | 9 | ||
| 8 | const otlpSerializationLayer = OtlpSerialization.layerJson; | 10 | const otlpSerializationLayer = OtlpSerialization.layerJson; |
| 9 | 11 | ||
| ... | @@ -16,9 +18,14 @@ export const ObservabilityLive = Layer.unwrap( | ... | @@ -16,9 +18,14 @@ export const ObservabilityLive = Layer.unwrap( |
| 16 | Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled), | 18 | Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled), |
| 17 | ); | 19 | ); |
| 18 | 20 | ||
| 19 | const tracerLayer = Layer.effect( | 21 | const tracerLayer = Layer.unwrap( |
| 20 | Tracer.Tracer, | ||
| 21 | Effect.gen(function* () { | 22 | Effect.gen(function* () { |
| 23 | const sink = yield* makeTraceSink({ | ||
| 24 | filePath: config.serverTracePath, | ||
| 25 | maxBytes: config.traceMaxBytes, | ||
| 26 | maxFiles: config.traceMaxFiles, | ||
| 27 | batchWindowMs: config.traceBatchWindowMs, | ||
| 28 | }); | ||
| 22 | const delegate = | 29 | const delegate = |
| 23 | config.otlpTracesUrl === undefined | 30 | config.otlpTracesUrl === undefined |
| 24 | ? undefined | 31 | ? undefined |
| ... | @@ -34,13 +41,26 @@ export const ObservabilityLive = Layer.unwrap( | ... | @@ -34,13 +41,26 @@ export const ObservabilityLive = Layer.unwrap( |
| 34 | }, | 41 | }, |
| 35 | }); | 42 | }); |
| 36 | 43 | ||
| 37 | return yield* makeLocalFileTracer({ | 44 | const tracer = yield* makeLocalFileTracer({ |
| 38 | filePath: config.serverTracePath, | 45 | filePath: config.serverTracePath, |
| 39 | maxBytes: config.traceMaxBytes, | 46 | maxBytes: config.traceMaxBytes, |
| 40 | maxFiles: config.traceMaxFiles, | 47 | maxFiles: config.traceMaxFiles, |
| 41 | batchWindowMs: config.traceBatchWindowMs, | 48 | batchWindowMs: config.traceBatchWindowMs, |
| 49 | sink, | ||
| 42 | ...(delegate ? { delegate } : {}), | 50 | ...(delegate ? { delegate } : {}), |
| 43 | }); | 51 | }); |
| 52 | |||
| 53 | return Layer.mergeAll( | ||
| 54 | Layer.succeed(Tracer.Tracer, tracer), | ||
| 55 | Layer.succeed(BrowserTraceCollector, { | ||
| 56 | record: (records) => | ||
| 57 | Effect.sync(() => { | ||
| 58 | for (const record of records) { | ||
| 59 | sink.push(record); | ||
| 60 | } | ||
| 61 | }), | ||
| 62 | }), | ||
| 63 | ); | ||
| 44 | }), | 64 | }), |
| 45 | ).pipe(Layer.provideMerge(otlpSerializationLayer)); | 65 | ).pipe(Layer.provideMerge(otlpSerializationLayer)); |
| 46 | 66 |
apps/server/src/observability/LocalFileTracer.test.ts+3-3| ... | @@ -5,7 +5,7 @@ import path from "node:path"; | ... | @@ -5,7 +5,7 @@ import path from "node:path"; |
| 5 | import { assert, describe, it } from "@effect/vitest"; | 5 | import { assert, describe, it } from "@effect/vitest"; |
| 6 | import { Effect, Layer, Logger, References, Tracer } from "effect"; | 6 | import { Effect, Layer, Logger, References, Tracer } from "effect"; |
| 7 | 7 | ||
| 8 | import type { TraceRecord } from "./TraceRecord.ts"; | 8 | import type { EffectTraceRecord } from "./TraceRecord.ts"; |
| 9 | import { makeLocalFileTracer } from "./LocalFileTracer.ts"; | 9 | import { makeLocalFileTracer } from "./LocalFileTracer.ts"; |
| 10 | 10 | ||
| 11 | const makeTestLayer = (tracePath: string) => | 11 | const makeTestLayer = (tracePath: string) => |
| ... | @@ -23,13 +23,13 @@ const makeTestLayer = (tracePath: string) => | ... | @@ -23,13 +23,13 @@ const makeTestLayer = (tracePath: string) => |
| 23 | Layer.succeed(References.MinimumLogLevel, "Info"), | 23 | Layer.succeed(References.MinimumLogLevel, "Info"), |
| 24 | ); | 24 | ); |
| 25 | 25 | ||
| 26 | const readTraceRecords = (tracePath: string): Array<TraceRecord> => | 26 | const readTraceRecords = (tracePath: string): Array<EffectTraceRecord> => |
| 27 | fs | 27 | fs |
| 28 | .readFileSync(tracePath, "utf8") | 28 | .readFileSync(tracePath, "utf8") |
| 29 | .trim() | 29 | .trim() |
| 30 | .split("\n") | 30 | .split("\n") |
| 31 | .filter((line) => line.length > 0) | 31 | .filter((line) => line.length > 0) |
| 32 | .map((line) => JSON.parse(line) as TraceRecord); | 32 | .map((line) => JSON.parse(line) as EffectTraceRecord); |
| 33 | 33 | ||
| 34 | describe("LocalFileTracer", () => { | 34 | describe("LocalFileTracer", () => { |
| 35 | it.effect("writes nested spans to disk and captures log messages as span events", () => | 35 | it.effect("writes nested spans to disk and captures log messages as span events", () => |
apps/server/src/observability/LocalFileTracer.ts+12-9| ... | @@ -1,8 +1,8 @@ | ... | @@ -1,8 +1,8 @@ |
| 1 | import type * as Exit from "effect/Exit"; | 1 | import type * as Exit from "effect/Exit"; |
| 2 | import { Effect, Option, Tracer } from "effect"; | 2 | import { Effect, Option, Tracer } from "effect"; |
| 3 | 3 | ||
| 4 | import { spanToTraceRecord } from "./TraceRecord.ts"; | 4 | import { EffectTraceRecord, spanToTraceRecord } from "./TraceRecord.ts"; |
| 5 | import { makeTraceSink } from "./TraceSink.ts"; | 5 | import { makeTraceSink, type TraceSink } from "./TraceSink.ts"; |
| 6 | 6 | ||
| 7 | export interface LocalFileTracerOptions { | 7 | export interface LocalFileTracerOptions { |
| 8 | readonly filePath: string; | 8 | readonly filePath: string; |
| ... | @@ -10,6 +10,7 @@ export interface LocalFileTracerOptions { | ... | @@ -10,6 +10,7 @@ export interface LocalFileTracerOptions { |
| 10 | readonly maxFiles: number; | 10 | readonly maxFiles: number; |
| 11 | readonly batchWindowMs: number; | 11 | readonly batchWindowMs: number; |
| 12 | readonly delegate?: Tracer.Tracer; | 12 | readonly delegate?: Tracer.Tracer; |
| 13 | readonly sink?: TraceSink; | ||
| 13 | } | 14 | } |
| 14 | 15 | ||
| 15 | class LocalFileSpan implements Tracer.Span { | 16 | class LocalFileSpan implements Tracer.Span { |
| ... | @@ -30,7 +31,7 @@ class LocalFileSpan implements Tracer.Span { | ... | @@ -30,7 +31,7 @@ class LocalFileSpan implements Tracer.Span { |
| 30 | constructor( | 31 | constructor( |
| 31 | options: Parameters<Tracer.Tracer["span"]>[0], | 32 | options: Parameters<Tracer.Tracer["span"]>[0], |
| 32 | private readonly delegate: Tracer.Span, | 33 | private readonly delegate: Tracer.Span, |
| 33 | private readonly push: (record: ReturnType<typeof spanToTraceRecord>) => void, | 34 | private readonly push: (record: EffectTraceRecord) => void, |
| 34 | ) { | 35 | ) { |
| 35 | this.name = delegate.name; | 36 | this.name = delegate.name; |
| 36 | this.spanId = delegate.spanId; | 37 | this.spanId = delegate.spanId; |
| ... | @@ -82,12 +83,14 @@ class LocalFileSpan implements Tracer.Span { | ... | @@ -82,12 +83,14 @@ class LocalFileSpan implements Tracer.Span { |
| 82 | export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* ( | 83 | export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* ( |
| 83 | options: LocalFileTracerOptions, | 84 | options: LocalFileTracerOptions, |
| 84 | ) { | 85 | ) { |
| 85 | const sink = yield* makeTraceSink({ | 86 | const sink = |
| 86 | filePath: options.filePath, | 87 | options.sink ?? |
| 87 | maxBytes: options.maxBytes, | 88 | (yield* makeTraceSink({ |
| 88 | maxFiles: options.maxFiles, | 89 | filePath: options.filePath, |
| 89 | batchWindowMs: options.batchWindowMs, | 90 | maxBytes: options.maxBytes, |
| 90 | }); | 91 | maxFiles: options.maxFiles, |
| 92 | batchWindowMs: options.batchWindowMs, | ||
| 93 | })); | ||
| 91 | 94 | ||
| 92 | const delegate = | 95 | const delegate = |
| 93 | options.delegate ?? | 96 | options.delegate ?? |
apps/server/src/observability/Services/BrowserTraceCollector.ts created+13| ... | @@ -0,0 +1,13 @@ | ||
| 1 | import { ServiceMap } from "effect"; | ||
| 2 | import type { Effect } from "effect"; | ||
| 3 | |||
| 4 | import type { TraceRecord } from "../TraceRecord.ts"; | ||
| 5 | |||
| 6 | export interface BrowserTraceCollectorShape { | ||
| 7 | readonly record: (records: ReadonlyArray<TraceRecord>) => Effect.Effect<void>; | ||
| 8 | } | ||
| 9 | |||
| 10 | export class BrowserTraceCollector extends ServiceMap.Service< | ||
| 11 | BrowserTraceCollector, | ||
| 12 | BrowserTraceCollectorShape | ||
| 13 | >()("t3/observability/Services/BrowserTraceCollector") {} | ||
apps/server/src/observability/TraceRecord.ts+202-15| ... | @@ -1,29 +1,37 @@ | ... | @@ -1,29 +1,37 @@ |
| 1 | import { Cause, Exit, Option, Tracer } from "effect"; | 1 | import { Cause, Exit, Option, Tracer } from "effect"; |
| 2 | 2 | ||
| 3 | import { compactTraceAttributes } from "./Attributes.ts"; | 3 | import { compactTraceAttributes } from "./Attributes.ts"; |
| 4 | import { OtlpResource, OtlpTracer } from "effect/unstable/observability"; | ||
| 4 | 5 | ||
| 5 | export interface TraceRecord { | 6 | interface TraceRecordEvent { |
| 6 | readonly type: "effect-span"; | 7 | readonly name: string; |
| 8 | readonly timeUnixNano: string; | ||
| 9 | readonly attributes: Readonly<Record<string, unknown>>; | ||
| 10 | } | ||
| 11 | |||
| 12 | interface TraceRecordLink { | ||
| 13 | readonly traceId: string; | ||
| 14 | readonly spanId: string; | ||
| 15 | readonly attributes: Readonly<Record<string, unknown>>; | ||
| 16 | } | ||
| 17 | |||
| 18 | interface BaseTraceRecord { | ||
| 7 | readonly name: string; | 19 | readonly name: string; |
| 20 | readonly kind: string; | ||
| 8 | readonly traceId: string; | 21 | readonly traceId: string; |
| 9 | readonly spanId: string; | 22 | readonly spanId: string; |
| 10 | readonly parentSpanId?: string; | 23 | readonly parentSpanId?: string; |
| 11 | readonly sampled: boolean; | 24 | readonly sampled: boolean; |
| 12 | readonly kind: Tracer.SpanKind; | ||
| 13 | readonly startTimeUnixNano: string; | 25 | readonly startTimeUnixNano: string; |
| 14 | readonly endTimeUnixNano: string; | 26 | readonly endTimeUnixNano: string; |
| 15 | readonly durationMs: number; | 27 | readonly durationMs: number; |
| 16 | readonly attributes: Readonly<Record<string, unknown>>; | 28 | readonly attributes: Readonly<Record<string, unknown>>; |
| 17 | readonly events: ReadonlyArray<{ | 29 | readonly events: ReadonlyArray<TraceRecordEvent>; |
| 18 | readonly name: string; | 30 | readonly links: ReadonlyArray<TraceRecordLink>; |
| 19 | readonly timeUnixNano: string; | 31 | } |
| 20 | readonly attributes: Readonly<Record<string, unknown>>; | 32 | |
| 21 | }>; | 33 | export interface EffectTraceRecord extends BaseTraceRecord { |
| 22 | readonly links: ReadonlyArray<{ | 34 | readonly type: "effect-span"; |
| 23 | readonly traceId: string; | ||
| 24 | readonly spanId: string; | ||
| 25 | readonly attributes: Readonly<Record<string, unknown>>; | ||
| 26 | }>; | ||
| 27 | readonly exit: | 35 | readonly exit: |
| 28 | | { | 36 | | { |
| 29 | readonly _tag: "Success"; | 37 | readonly _tag: "Success"; |
| ... | @@ -38,6 +46,29 @@ export interface TraceRecord { | ... | @@ -38,6 +46,29 @@ export interface TraceRecord { |
| 38 | }; | 46 | }; |
| 39 | } | 47 | } |
| 40 | 48 | ||
| 49 | interface OtlpTraceRecord extends BaseTraceRecord { | ||
| 50 | readonly type: "otlp-span"; | ||
| 51 | readonly resourceAttributes: Readonly<Record<string, unknown>>; | ||
| 52 | readonly scope: Readonly<{ | ||
| 53 | readonly name?: string; | ||
| 54 | readonly version?: string; | ||
| 55 | readonly attributes: Readonly<Record<string, unknown>>; | ||
| 56 | }>; | ||
| 57 | readonly status?: | ||
| 58 | | { | ||
| 59 | readonly code?: string; | ||
| 60 | readonly message?: string; | ||
| 61 | } | ||
| 62 | | undefined; | ||
| 63 | } | ||
| 64 | |||
| 65 | export type TraceRecord = EffectTraceRecord | OtlpTraceRecord; | ||
| 66 | |||
| 67 | type OtlpSpan = OtlpTracer.ScopeSpan["spans"][number]; | ||
| 68 | type OtlpSpanEvent = OtlpSpan["events"][number]; | ||
| 69 | type OtlpSpanLink = OtlpSpan["links"][number]; | ||
| 70 | type OtlpSpanStatus = OtlpSpan["status"]; | ||
| 71 | |||
| 41 | interface SerializableSpan { | 72 | interface SerializableSpan { |
| 42 | readonly name: string; | 73 | readonly name: string; |
| 43 | readonly traceId: string; | 74 | readonly traceId: string; |
| ... | @@ -53,7 +84,7 @@ interface SerializableSpan { | ... | @@ -53,7 +84,7 @@ interface SerializableSpan { |
| 53 | >; | 84 | >; |
| 54 | } | 85 | } |
| 55 | 86 | ||
| 56 | function formatTraceExit(exit: Exit.Exit<unknown, unknown>): TraceRecord["exit"] { | 87 | function formatTraceExit(exit: Exit.Exit<unknown, unknown>): EffectTraceRecord["exit"] { |
| 57 | if (Exit.isSuccess(exit)) { | 88 | if (Exit.isSuccess(exit)) { |
| 58 | return { _tag: "Success" }; | 89 | return { _tag: "Success" }; |
| 59 | } | 90 | } |
| ... | @@ -69,7 +100,7 @@ function formatTraceExit(exit: Exit.Exit<unknown, unknown>): TraceRecord["exit"] | ... | @@ -69,7 +100,7 @@ function formatTraceExit(exit: Exit.Exit<unknown, unknown>): TraceRecord["exit"] |
| 69 | }; | 100 | }; |
| 70 | } | 101 | } |
| 71 | 102 | ||
| 72 | export function spanToTraceRecord(span: SerializableSpan): TraceRecord { | 103 | export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord { |
| 73 | const status = span.status as Extract<Tracer.SpanStatus, { _tag: "Ended" }>; | 104 | const status = span.status as Extract<Tracer.SpanStatus, { _tag: "Ended" }>; |
| 74 | const parentSpanId = Option.getOrUndefined(span.parent)?.spanId; | 105 | const parentSpanId = Option.getOrUndefined(span.parent)?.spanId; |
| 75 | 106 | ||
| ... | @@ -98,3 +129,159 @@ export function spanToTraceRecord(span: SerializableSpan): TraceRecord { | ... | @@ -98,3 +129,159 @@ export function spanToTraceRecord(span: SerializableSpan): TraceRecord { |
| 98 | exit: formatTraceExit(status.exit), | 129 | exit: formatTraceExit(status.exit), |
| 99 | }; | 130 | }; |
| 100 | } | 131 | } |
| 132 | |||
| 133 | const SPAN_KIND_MAP: Record<number, OtlpTraceRecord["kind"]> = { | ||
| 134 | 1: "internal", | ||
| 135 | 2: "server", | ||
| 136 | 3: "client", | ||
| 137 | 4: "producer", | ||
| 138 | 5: "consumer", | ||
| 139 | }; | ||
| 140 | |||
| 141 | export function decodeOtlpTraceRecords( | ||
| 142 | payload: OtlpTracer.TraceData, | ||
| 143 | ): ReadonlyArray<OtlpTraceRecord> { | ||
| 144 | const records: Array<OtlpTraceRecord> = []; | ||
| 145 | |||
| 146 | for (const resourceSpan of payload.resourceSpans) { | ||
| 147 | const resourceAttributes = decodeAttributes(resourceSpan.resource?.attributes ?? []); | ||
| 148 | |||
| 149 | for (const scopeSpan of resourceSpan.scopeSpans) { | ||
| 150 | for (const span of scopeSpan.spans) { | ||
| 151 | records.push( | ||
| 152 | otlpSpanToTraceRecord({ | ||
| 153 | resourceAttributes, | ||
| 154 | scopeAttributes: decodeAttributes( | ||
| 155 | "attributes" in scopeSpan.scope && Array.isArray(scopeSpan.scope.attributes) | ||
| 156 | ? scopeSpan.scope.attributes | ||
| 157 | : [], | ||
| 158 | ), | ||
| 159 | scopeName: scopeSpan.scope.name, | ||
| 160 | scopeVersion: | ||
| 161 | "version" in scopeSpan.scope && typeof scopeSpan.scope.version === "string" | ||
| 162 | ? scopeSpan.scope.version | ||
| 163 | : undefined, | ||
| 164 | span, | ||
| 165 | }), | ||
| 166 | ); | ||
| 167 | } | ||
| 168 | } | ||
| 169 | } | ||
| 170 | |||
| 171 | return records; | ||
| 172 | } | ||
| 173 | |||
| 174 | function otlpSpanToTraceRecord(input: { | ||
| 175 | readonly resourceAttributes: Readonly<Record<string, unknown>>; | ||
| 176 | readonly scopeAttributes: Readonly<Record<string, unknown>>; | ||
| 177 | readonly scopeName: string | undefined; | ||
| 178 | readonly scopeVersion: string | undefined; | ||
| 179 | readonly span: OtlpSpan; | ||
| 180 | }): OtlpTraceRecord { | ||
| 181 | return { | ||
| 182 | type: "otlp-span", | ||
| 183 | name: input.span.name, | ||
| 184 | traceId: input.span.traceId, | ||
| 185 | spanId: input.span.spanId, | ||
| 186 | ...(input.span.parentSpanId ? { parentSpanId: input.span.parentSpanId } : {}), | ||
| 187 | sampled: true, | ||
| 188 | kind: normalizeSpanKind(input.span.kind), | ||
| 189 | startTimeUnixNano: input.span.startTimeUnixNano, | ||
| 190 | endTimeUnixNano: input.span.endTimeUnixNano, | ||
| 191 | durationMs: | ||
| 192 | Number(parseBigInt(input.span.endTimeUnixNano) - parseBigInt(input.span.startTimeUnixNano)) / | ||
| 193 | 1_000_000, | ||
| 194 | attributes: decodeAttributes(input.span.attributes), | ||
| 195 | resourceAttributes: input.resourceAttributes, | ||
| 196 | scope: { | ||
| 197 | ...(input.scopeName ? { name: input.scopeName } : {}), | ||
| 198 | ...(input.scopeVersion ? { version: input.scopeVersion } : {}), | ||
| 199 | attributes: input.scopeAttributes, | ||
| 200 | }, | ||
| 201 | events: decodeEvents(input.span.events), | ||
| 202 | links: decodeLinks(input.span.links), | ||
| 203 | status: decodeStatus(input.span.status), | ||
| 204 | }; | ||
| 205 | } | ||
| 206 | |||
| 207 | function decodeStatus(input: OtlpSpanStatus): OtlpTraceRecord["status"] { | ||
| 208 | const code = String(input.code); | ||
| 209 | const message = input.message; | ||
| 210 | |||
| 211 | return { | ||
| 212 | code, | ||
| 213 | ...(message ? { message } : {}), | ||
| 214 | }; | ||
| 215 | } | ||
| 216 | |||
| 217 | function decodeEvents(input: ReadonlyArray<OtlpSpanEvent>): ReadonlyArray<TraceRecordEvent> { | ||
| 218 | return input.map((current) => ({ | ||
| 219 | name: current.name, | ||
| 220 | timeUnixNano: current.timeUnixNano, | ||
| 221 | attributes: decodeAttributes(current.attributes), | ||
| 222 | })); | ||
| 223 | } | ||
| 224 | |||
| 225 | function decodeLinks(input: ReadonlyArray<OtlpSpanLink>): ReadonlyArray<TraceRecordLink> { | ||
| 226 | return input.flatMap((current) => { | ||
| 227 | const traceId = current.traceId; | ||
| 228 | const spanId = current.spanId; | ||
| 229 | return { | ||
| 230 | traceId, | ||
| 231 | spanId, | ||
| 232 | attributes: decodeAttributes(current.attributes), | ||
| 233 | }; | ||
| 234 | }); | ||
| 235 | } | ||
| 236 | |||
| 237 | function decodeAttributes( | ||
| 238 | input: ReadonlyArray<OtlpResource.KeyValue>, | ||
| 239 | ): Readonly<Record<string, unknown>> { | ||
| 240 | const entries: Record<string, unknown> = {}; | ||
| 241 | |||
| 242 | for (const attribute of input) { | ||
| 243 | entries[attribute.key] = decodeValue(attribute.value); | ||
| 244 | } | ||
| 245 | |||
| 246 | return compactTraceAttributes(entries); | ||
| 247 | } | ||
| 248 | |||
| 249 | function decodeValue(input: OtlpResource.AnyValue | null | undefined): unknown { | ||
| 250 | if (input == null) { | ||
| 251 | return null; | ||
| 252 | } | ||
| 253 | if ("stringValue" in input) { | ||
| 254 | return input.stringValue; | ||
| 255 | } | ||
| 256 | if ("boolValue" in input) { | ||
| 257 | return input.boolValue; | ||
| 258 | } | ||
| 259 | if ("intValue" in input) { | ||
| 260 | return input.intValue; | ||
| 261 | } | ||
| 262 | if ("doubleValue" in input) { | ||
| 263 | return input.doubleValue; | ||
| 264 | } | ||
| 265 | if ("bytesValue" in input) { | ||
| 266 | return input.bytesValue; | ||
| 267 | } | ||
| 268 | if (input.arrayValue) { | ||
| 269 | return input.arrayValue.values.map((entry) => decodeValue(entry)); | ||
| 270 | } | ||
| 271 | if (input.kvlistValue) { | ||
| 272 | return decodeAttributes(input.kvlistValue.values); | ||
| 273 | } | ||
| 274 | return null; | ||
| 275 | } | ||
| 276 | |||
| 277 | function normalizeSpanKind(input: number): OtlpTraceRecord["kind"] { | ||
| 278 | return SPAN_KIND_MAP[input] || "internal"; | ||
| 279 | } | ||
| 280 | |||
| 281 | function parseBigInt(input: string): bigint { | ||
| 282 | try { | ||
| 283 | return BigInt(input); | ||
| 284 | } catch { | ||
| 285 | return 0n; | ||
| 286 | } | ||
| 287 | } |
apps/server/src/server.test.ts+408-2| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | 1 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; |
| 2 | import * as NodeSocket from "@effect/platform-node/NodeSocket"; | 2 | import * as NodeSocket from "@effect/platform-node/NodeSocket"; |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | |||
| 4 | import { | 5 | import { |
| 5 | CommandId, | 6 | CommandId, |
| 6 | DEFAULT_SERVER_SETTINGS, | 7 | DEFAULT_SERVER_SETTINGS, |
| ... | @@ -21,8 +22,15 @@ import { | ... | @@ -21,8 +22,15 @@ import { |
| 21 | } from "@t3tools/contracts"; | 22 | } from "@t3tools/contracts"; |
| 22 | import { assert, it } from "@effect/vitest"; | 23 | import { assert, it } from "@effect/vitest"; |
| 23 | import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; | 24 | import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; |
| 24 | import { Effect, FileSystem, Layer, Path, Stream } from "effect"; | 25 | import { Effect, FileSystem, Layer, ManagedRuntime, Path, Stream } from "effect"; |
| 25 | import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; | 26 | import { |
| 27 | FetchHttpClient, | ||
| 28 | HttpBody, | ||
| 29 | HttpClient, | ||
| 30 | HttpRouter, | ||
| 31 | HttpServer, | ||
| 32 | } from "effect/unstable/http"; | ||
| 33 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; | ||
| 26 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; | 34 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; |
| 27 | import { vi } from "vitest"; | 35 | import { vi } from "vitest"; |
| 28 | 36 | ||
| ... | @@ -56,6 +64,10 @@ import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./server | ... | @@ -56,6 +64,10 @@ import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./server |
| 56 | import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; | 64 | import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; |
| 57 | import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; | 65 | import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; |
| 58 | import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; | 66 | import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; |
| 67 | import { | ||
| 68 | BrowserTraceCollector, | ||
| 69 | type BrowserTraceCollectorShape, | ||
| 70 | } from "./observability/Services/BrowserTraceCollector.ts"; | ||
| 59 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; | 71 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; |
| 60 | import { | 72 | import { |
| 61 | ProjectSetupScriptRunner, | 73 | ProjectSetupScriptRunner, |
| ... | @@ -124,6 +136,113 @@ const workspaceAndProjectServicesLayer = Layer.mergeAll( | ... | @@ -124,6 +136,113 @@ const workspaceAndProjectServicesLayer = Layer.mergeAll( |
| 124 | ProjectFaviconResolverLive, | 136 | ProjectFaviconResolverLive, |
| 125 | ); | 137 | ); |
| 126 | 138 | ||
| 139 | const browserOtlpTracingLayer = Layer.mergeAll( | ||
| 140 | FetchHttpClient.layer, | ||
| 141 | OtlpSerialization.layerJson, | ||
| 142 | Layer.succeed(HttpClient.TracerDisabledWhen, () => true), | ||
| 143 | ); | ||
| 144 | |||
| 145 | const makeBrowserOtlpPayload = (spanName: string) => | ||
| 146 | Effect.gen(function* () { | ||
| 147 | const collector = yield* Effect.acquireRelease( | ||
| 148 | Effect.promise(async () => { | ||
| 149 | const NodeHttp = await import("node:http"); | ||
| 150 | |||
| 151 | return await new Promise<{ | ||
| 152 | readonly close: () => Promise<void>; | ||
| 153 | readonly firstRequest: Promise<{ | ||
| 154 | readonly body: string; | ||
| 155 | readonly contentType: string | null; | ||
| 156 | }>; | ||
| 157 | readonly url: string; | ||
| 158 | }>((resolve, reject) => { | ||
| 159 | let resolveFirstRequest: | ||
| 160 | | ((request: { readonly body: string; readonly contentType: string | null }) => void) | ||
| 161 | | undefined; | ||
| 162 | const firstRequest = new Promise<{ | ||
| 163 | readonly body: string; | ||
| 164 | readonly contentType: string | null; | ||
| 165 | }>((resolveRequest) => { | ||
| 166 | resolveFirstRequest = resolveRequest; | ||
| 167 | }); | ||
| 168 | |||
| 169 | const server = NodeHttp.createServer((request, response) => { | ||
| 170 | const chunks: Buffer[] = []; | ||
| 171 | request.on("data", (chunk) => { | ||
| 172 | chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); | ||
| 173 | }); | ||
| 174 | request.on("end", () => { | ||
| 175 | resolveFirstRequest?.({ | ||
| 176 | body: Buffer.concat(chunks).toString("utf8"), | ||
| 177 | contentType: request.headers["content-type"] ?? null, | ||
| 178 | }); | ||
| 179 | resolveFirstRequest = undefined; | ||
| 180 | response.statusCode = 204; | ||
| 181 | response.end(); | ||
| 182 | }); | ||
| 183 | }); | ||
| 184 | |||
| 185 | server.on("error", reject); | ||
| 186 | server.listen(0, "127.0.0.1", () => { | ||
| 187 | const address = server.address(); | ||
| 188 | if (!address || typeof address === "string") { | ||
| 189 | reject(new Error("Expected TCP collector address")); | ||
| 190 | return; | ||
| 191 | } | ||
| 192 | |||
| 193 | resolve({ | ||
| 194 | url: `http://127.0.0.1:${address.port}/v1/traces`, | ||
| 195 | firstRequest, | ||
| 196 | close: () => | ||
| 197 | new Promise<void>((resolveClose, rejectClose) => { | ||
| 198 | server.close((error) => { | ||
| 199 | if (error) { | ||
| 200 | rejectClose(error); | ||
| 201 | return; | ||
| 202 | } | ||
| 203 | resolveClose(); | ||
| 204 | }); | ||
| 205 | }), | ||
| 206 | }); | ||
| 207 | }); | ||
| 208 | }); | ||
| 209 | }), | ||
| 210 | ({ close }) => Effect.promise(close), | ||
| 211 | ); | ||
| 212 | |||
| 213 | const runtime = ManagedRuntime.make( | ||
| 214 | OtlpTracer.layer({ | ||
| 215 | url: collector.url, | ||
| 216 | exportInterval: "10 millis", | ||
| 217 | resource: { | ||
| 218 | serviceName: "t3-web", | ||
| 219 | attributes: { | ||
| 220 | "service.runtime": "t3-web", | ||
| 221 | "service.mode": "browser", | ||
| 222 | "service.version": "test", | ||
| 223 | }, | ||
| 224 | }, | ||
| 225 | }).pipe(Layer.provide(browserOtlpTracingLayer)), | ||
| 226 | ); | ||
| 227 | |||
| 228 | try { | ||
| 229 | yield* Effect.promise(() => runtime.runPromise(Effect.void.pipe(Effect.withSpan(spanName)))); | ||
| 230 | } finally { | ||
| 231 | yield* Effect.promise(() => runtime.dispose()); | ||
| 232 | } | ||
| 233 | |||
| 234 | const request = yield* Effect.promise(() => | ||
| 235 | Promise.race([ | ||
| 236 | collector.firstRequest, | ||
| 237 | new Promise<never>((_, reject) => { | ||
| 238 | setTimeout(() => reject(new Error("Timed out waiting for OTLP trace export")), 1_000); | ||
| 239 | }), | ||
| 240 | ]), | ||
| 241 | ); | ||
| 242 | |||
| 243 | return JSON.parse(request.body) as OtlpTracer.TraceData; | ||
| 244 | }); | ||
| 245 | |||
| 127 | const buildAppUnderTest = (options?: { | 246 | const buildAppUnderTest = (options?: { |
| 128 | config?: Partial<ServerConfigShape>; | 247 | config?: Partial<ServerConfigShape>; |
| 129 | layers?: { | 248 | layers?: { |
| ... | @@ -138,6 +257,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -138,6 +257,7 @@ const buildAppUnderTest = (options?: { |
| 138 | orchestrationEngine?: Partial<OrchestrationEngineShape>; | 257 | orchestrationEngine?: Partial<OrchestrationEngineShape>; |
| 139 | projectionSnapshotQuery?: Partial<ProjectionSnapshotQueryShape>; | 258 | projectionSnapshotQuery?: Partial<ProjectionSnapshotQueryShape>; |
| 140 | checkpointDiffQuery?: Partial<CheckpointDiffQueryShape>; | 259 | checkpointDiffQuery?: Partial<CheckpointDiffQueryShape>; |
| 260 | browserTraceCollector?: Partial<BrowserTraceCollectorShape>; | ||
| 141 | serverLifecycleEvents?: Partial<ServerLifecycleEventsShape>; | 261 | serverLifecycleEvents?: Partial<ServerLifecycleEventsShape>; |
| 142 | serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>; | 262 | serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>; |
| 143 | }; | 263 | }; |
| ... | @@ -263,6 +383,12 @@ const buildAppUnderTest = (options?: { | ... | @@ -263,6 +383,12 @@ const buildAppUnderTest = (options?: { |
| 263 | ...options?.layers?.checkpointDiffQuery, | 383 | ...options?.layers?.checkpointDiffQuery, |
| 264 | }), | 384 | }), |
| 265 | ), | 385 | ), |
| 386 | Layer.provide( | ||
| 387 | Layer.mock(BrowserTraceCollector)({ | ||
| 388 | record: () => Effect.void, | ||
| 389 | ...options?.layers?.browserTraceCollector, | ||
| 390 | }), | ||
| 391 | ), | ||
| 266 | Layer.provide( | 392 | Layer.provide( |
| 267 | Layer.mock(ServerLifecycleEvents)({ | 393 | Layer.mock(ServerLifecycleEvents)({ |
| 268 | publish: (event) => Effect.succeed({ ...(event as any), sequence: 1 }), | 394 | publish: (event) => Effect.succeed({ ...(event as any), sequence: 1 }), |
| ... | @@ -280,6 +406,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -280,6 +406,7 @@ const buildAppUnderTest = (options?: { |
| 280 | }), | 406 | }), |
| 281 | ), | 407 | ), |
| 282 | Layer.provide(workspaceAndProjectServicesLayer), | 408 | Layer.provide(workspaceAndProjectServicesLayer), |
| 409 | Layer.provideMerge(FetchHttpClient.layer), | ||
| 283 | Layer.provide(layerConfig), | 410 | Layer.provide(layerConfig), |
| 284 | ); | 411 | ); |
| 285 | 412 | ||
| ... | @@ -437,6 +564,285 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -437,6 +564,285 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 437 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 564 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 438 | ); | 565 | ); |
| 439 | 566 | ||
| 567 | it.effect("proxies browser OTLP trace exports through the server", () => | ||
| 568 | Effect.gen(function* () { | ||
| 569 | const upstreamRequests: Array<{ | ||
| 570 | readonly body: string; | ||
| 571 | readonly contentType: string | null; | ||
| 572 | }> = []; | ||
| 573 | const localTraceRecords: Array<unknown> = []; | ||
| 574 | const payload = { | ||
| 575 | resourceSpans: [ | ||
| 576 | { | ||
| 577 | resource: { | ||
| 578 | attributes: [ | ||
| 579 | { | ||
| 580 | key: "service.name", | ||
| 581 | value: { stringValue: "t3-web" }, | ||
| 582 | }, | ||
| 583 | ], | ||
| 584 | }, | ||
| 585 | scopeSpans: [ | ||
| 586 | { | ||
| 587 | scope: { | ||
| 588 | name: "effect", | ||
| 589 | version: "4.0.0-beta.43", | ||
| 590 | }, | ||
| 591 | spans: [ | ||
| 592 | { | ||
| 593 | traceId: "11111111111111111111111111111111", | ||
| 594 | spanId: "2222222222222222", | ||
| 595 | parentSpanId: "3333333333333333", | ||
| 596 | name: "RpcClient.server.getSettings", | ||
| 597 | kind: 3, | ||
| 598 | startTimeUnixNano: "1000000", | ||
| 599 | endTimeUnixNano: "2000000", | ||
| 600 | attributes: [ | ||
| 601 | { | ||
| 602 | key: "rpc.method", | ||
| 603 | value: { stringValue: "server.getSettings" }, | ||
| 604 | }, | ||
| 605 | ], | ||
| 606 | events: [ | ||
| 607 | { | ||
| 608 | name: "http.request", | ||
| 609 | timeUnixNano: "1500000", | ||
| 610 | attributes: [ | ||
| 611 | { | ||
| 612 | key: "http.status_code", | ||
| 613 | value: { intValue: "200" }, | ||
| 614 | }, | ||
| 615 | ], | ||
| 616 | }, | ||
| 617 | ], | ||
| 618 | links: [], | ||
| 619 | status: { | ||
| 620 | code: "STATUS_CODE_OK", | ||
| 621 | }, | ||
| 622 | flags: 1, | ||
| 623 | }, | ||
| 624 | ], | ||
| 625 | }, | ||
| 626 | ], | ||
| 627 | }, | ||
| 628 | ], | ||
| 629 | }; | ||
| 630 | |||
| 631 | const collector = yield* Effect.acquireRelease( | ||
| 632 | Effect.promise(async () => { | ||
| 633 | const NodeHttp = await import("node:http"); | ||
| 634 | |||
| 635 | return await new Promise<{ | ||
| 636 | readonly close: () => Promise<void>; | ||
| 637 | readonly url: string; | ||
| 638 | }>((resolve, reject) => { | ||
| 639 | const server = NodeHttp.createServer((request, response) => { | ||
| 640 | const chunks: Buffer[] = []; | ||
| 641 | request.on("data", (chunk) => { | ||
| 642 | chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); | ||
| 643 | }); | ||
| 644 | request.on("end", () => { | ||
| 645 | upstreamRequests.push({ | ||
| 646 | body: Buffer.concat(chunks).toString("utf8"), | ||
| 647 | contentType: request.headers["content-type"] ?? null, | ||
| 648 | }); | ||
| 649 | response.statusCode = 204; | ||
| 650 | response.end(); | ||
| 651 | }); | ||
| 652 | }); | ||
| 653 | |||
| 654 | server.on("error", reject); | ||
| 655 | server.listen(0, "127.0.0.1", () => { | ||
| 656 | const address = server.address(); | ||
| 657 | if (!address || typeof address === "string") { | ||
| 658 | reject(new Error("Expected TCP collector address")); | ||
| 659 | return; | ||
| 660 | } | ||
| 661 | |||
| 662 | resolve({ | ||
| 663 | url: `http://127.0.0.1:${address.port}/v1/traces`, | ||
| 664 | close: () => | ||
| 665 | new Promise<void>((resolveClose, rejectClose) => { | ||
| 666 | server.close((error) => { | ||
| 667 | if (error) { | ||
| 668 | rejectClose(error); | ||
| 669 | return; | ||
| 670 | } | ||
| 671 | resolveClose(); | ||
| 672 | }); | ||
| 673 | }), | ||
| 674 | }); | ||
| 675 | }); | ||
| 676 | }); | ||
| 677 | }), | ||
| 678 | ({ close }) => Effect.promise(close), | ||
| 679 | ); | ||
| 680 | |||
| 681 | yield* buildAppUnderTest({ | ||
| 682 | config: { | ||
| 683 | otlpTracesUrl: collector.url, | ||
| 684 | }, | ||
| 685 | layers: { | ||
| 686 | browserTraceCollector: { | ||
| 687 | record: (records) => | ||
| 688 | Effect.sync(() => { | ||
| 689 | localTraceRecords.push(...records); | ||
| 690 | }), | ||
| 691 | }, | ||
| 692 | }, | ||
| 693 | }); | ||
| 694 | |||
| 695 | const response = yield* HttpClient.post("/api/observability/v1/traces", { | ||
| 696 | headers: { | ||
| 697 | "content-type": "application/json", | ||
| 698 | origin: "http://localhost:5733", | ||
| 699 | }, | ||
| 700 | body: HttpBody.text(JSON.stringify(payload), "application/json"), | ||
| 701 | }); | ||
| 702 | |||
| 703 | assert.equal(response.status, 204); | ||
| 704 | assert.equal(response.headers["access-control-allow-origin"], "*"); | ||
| 705 | assert.deepEqual(localTraceRecords, [ | ||
| 706 | { | ||
| 707 | type: "otlp-span", | ||
| 708 | name: "RpcClient.server.getSettings", | ||
| 709 | traceId: "11111111111111111111111111111111", | ||
| 710 | spanId: "2222222222222222", | ||
| 711 | parentSpanId: "3333333333333333", | ||
| 712 | sampled: true, | ||
| 713 | kind: "client", | ||
| 714 | startTimeUnixNano: "1000000", | ||
| 715 | endTimeUnixNano: "2000000", | ||
| 716 | durationMs: 1, | ||
| 717 | attributes: { | ||
| 718 | "rpc.method": "server.getSettings", | ||
| 719 | }, | ||
| 720 | resourceAttributes: { | ||
| 721 | "service.name": "t3-web", | ||
| 722 | }, | ||
| 723 | scope: { | ||
| 724 | name: "effect", | ||
| 725 | version: "4.0.0-beta.43", | ||
| 726 | attributes: {}, | ||
| 727 | }, | ||
| 728 | events: [ | ||
| 729 | { | ||
| 730 | name: "http.request", | ||
| 731 | timeUnixNano: "1500000", | ||
| 732 | attributes: { | ||
| 733 | "http.status_code": "200", | ||
| 734 | }, | ||
| 735 | }, | ||
| 736 | ], | ||
| 737 | links: [], | ||
| 738 | status: { | ||
| 739 | code: "STATUS_CODE_OK", | ||
| 740 | }, | ||
| 741 | }, | ||
| 742 | ]); | ||
| 743 | assert.deepEqual(upstreamRequests, [ | ||
| 744 | { | ||
| 745 | body: JSON.stringify(payload), | ||
| 746 | contentType: "application/json", | ||
| 747 | }, | ||
| 748 | ]); | ||
| 749 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 750 | ); | ||
| 751 | |||
| 752 | it.effect("responds to browser OTLP trace preflight requests with CORS headers", () => | ||
| 753 | Effect.gen(function* () { | ||
| 754 | yield* buildAppUnderTest(); | ||
| 755 | |||
| 756 | const url = yield* getHttpServerUrl("/api/observability/v1/traces"); | ||
| 757 | const response = yield* Effect.promise(() => | ||
| 758 | fetch(url, { | ||
| 759 | method: "OPTIONS", | ||
| 760 | headers: { | ||
| 761 | origin: "http://localhost:5733", | ||
| 762 | "access-control-request-method": "POST", | ||
| 763 | "access-control-request-headers": "content-type", | ||
| 764 | }, | ||
| 765 | }), | ||
| 766 | ); | ||
| 767 | |||
| 768 | assert.equal(response.status, 204); | ||
| 769 | assert.equal(response.headers.get("access-control-allow-origin"), "*"); | ||
| 770 | assert.equal(response.headers.get("access-control-allow-methods"), "POST, OPTIONS"); | ||
| 771 | assert.equal(response.headers.get("access-control-allow-headers"), "content-type"); | ||
| 772 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 773 | ); | ||
| 774 | |||
| 775 | it.effect( | ||
| 776 | "stores browser OTLP trace exports locally when no upstream collector is configured", | ||
| 777 | () => | ||
| 778 | Effect.gen(function* () { | ||
| 779 | const localTraceRecords: Array<unknown> = []; | ||
| 780 | const payload = yield* makeBrowserOtlpPayload("client.test"); | ||
| 781 | const resourceSpan = payload.resourceSpans[0]; | ||
| 782 | const scopeSpan = resourceSpan?.scopeSpans[0]; | ||
| 783 | const span = scopeSpan?.spans[0]; | ||
| 784 | |||
| 785 | assert.notEqual(resourceSpan, undefined); | ||
| 786 | assert.notEqual(scopeSpan, undefined); | ||
| 787 | assert.notEqual(span, undefined); | ||
| 788 | if (!resourceSpan || !scopeSpan || !span) { | ||
| 789 | return; | ||
| 790 | } | ||
| 791 | |||
| 792 | yield* buildAppUnderTest({ | ||
| 793 | layers: { | ||
| 794 | browserTraceCollector: { | ||
| 795 | record: (records) => | ||
| 796 | Effect.sync(() => { | ||
| 797 | localTraceRecords.push(...records); | ||
| 798 | }), | ||
| 799 | }, | ||
| 800 | }, | ||
| 801 | }); | ||
| 802 | |||
| 803 | const response = yield* HttpClient.post("/api/observability/v1/traces", { | ||
| 804 | headers: { | ||
| 805 | "content-type": "application/json", | ||
| 806 | }, | ||
| 807 | body: HttpBody.text(JSON.stringify(payload), "application/json"), | ||
| 808 | }); | ||
| 809 | |||
| 810 | assert.equal(response.status, 204); | ||
| 811 | assert.equal(localTraceRecords.length, 1); | ||
| 812 | const record = localTraceRecords[0] as { | ||
| 813 | readonly type: string; | ||
| 814 | readonly name: string; | ||
| 815 | readonly traceId: string; | ||
| 816 | readonly spanId: string; | ||
| 817 | readonly kind: string; | ||
| 818 | readonly attributes: Readonly<Record<string, unknown>>; | ||
| 819 | readonly events: ReadonlyArray<unknown>; | ||
| 820 | readonly links: ReadonlyArray<unknown>; | ||
| 821 | readonly scope: { | ||
| 822 | readonly name?: string; | ||
| 823 | readonly attributes: Readonly<Record<string, unknown>>; | ||
| 824 | }; | ||
| 825 | readonly resourceAttributes: Readonly<Record<string, unknown>>; | ||
| 826 | readonly status?: { | ||
| 827 | readonly code?: string; | ||
| 828 | }; | ||
| 829 | }; | ||
| 830 | |||
| 831 | assert.equal(record.type, "otlp-span"); | ||
| 832 | assert.equal(record.name, span.name); | ||
| 833 | assert.equal(record.traceId, span.traceId); | ||
| 834 | assert.equal(record.spanId, span.spanId); | ||
| 835 | assert.equal(record.kind, "internal"); | ||
| 836 | assert.deepEqual(record.attributes, {}); | ||
| 837 | assert.deepEqual(record.events, []); | ||
| 838 | assert.deepEqual(record.links, []); | ||
| 839 | assert.equal(record.scope.name, scopeSpan.scope.name); | ||
| 840 | assert.deepEqual(record.scope.attributes, {}); | ||
| 841 | assert.equal(record.resourceAttributes["service.name"], "t3-web"); | ||
| 842 | assert.equal(record.status?.code, String(span.status.code)); | ||
| 843 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 844 | ); | ||
| 845 | |||
| 440 | it.effect("returns 404 for missing attachment id lookups", () => | 846 | it.effect("returns 404 for missing attachment id lookups", () => |
| 441 | Effect.gen(function* () { | 847 | Effect.gen(function* () { |
| 442 | yield* buildAppUnderTest(); | 848 | yield* buildAppUnderTest(); |
apps/server/src/server.ts+7-1| ... | @@ -2,7 +2,12 @@ import { Effect, Layer } from "effect"; | ... | @@ -2,7 +2,12 @@ import { Effect, Layer } from "effect"; |
| 2 | import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; | 2 | import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; |
| 3 | 3 | ||
| 4 | import { ServerConfig } from "./config"; | 4 | import { ServerConfig } from "./config"; |
| 5 | import { attachmentsRouteLayer, projectFaviconRouteLayer, staticAndDevRouteLayer } from "./http"; | 5 | import { |
| 6 | attachmentsRouteLayer, | ||
| 7 | otlpTracesProxyRouteLayer, | ||
| 8 | projectFaviconRouteLayer, | ||
| 9 | staticAndDevRouteLayer, | ||
| 10 | } from "./http"; | ||
| 6 | import { fixPath } from "./os-jank"; | 11 | import { fixPath } from "./os-jank"; |
| 7 | import { websocketRpcRouteLayer } from "./ws"; | 12 | import { websocketRpcRouteLayer } from "./ws"; |
| 8 | import { OpenLive } from "./open"; | 13 | import { OpenLive } from "./open"; |
| ... | @@ -205,6 +210,7 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( | ... | @@ -205,6 +210,7 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( |
| 205 | 210 | ||
| 206 | export const makeRoutesLayer = Layer.mergeAll( | 211 | export const makeRoutesLayer = Layer.mergeAll( |
| 207 | attachmentsRouteLayer, | 212 | attachmentsRouteLayer, |
| 213 | otlpTracesProxyRouteLayer, | ||
| 208 | projectFaviconRouteLayer, | 214 | projectFaviconRouteLayer, |
| 209 | staticAndDevRouteLayer, | 215 | staticAndDevRouteLayer, |
| 210 | websocketRpcRouteLayer, | 216 | websocketRpcRouteLayer, |
apps/web/src/observability/clientTracing.ts created+147| ... | @@ -0,0 +1,147 @@ | ||
| 1 | import { Exit, Layer, ManagedRuntime, Scope, Tracer } from "effect"; | ||
| 2 | import { FetchHttpClient, HttpClient } from "effect/unstable/http"; | ||
| 3 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; | ||
| 4 | |||
| 5 | import { isElectron } from "../env"; | ||
| 6 | import { resolveServerUrl } from "../lib/utils"; | ||
| 7 | import { APP_VERSION } from "~/branding"; | ||
| 8 | |||
| 9 | const DEFAULT_EXPORT_INTERVAL_MS = 1_000; | ||
| 10 | const CLIENT_TRACING_RESOURCE = { | ||
| 11 | serviceName: "t3-web", | ||
| 12 | attributes: { | ||
| 13 | "service.runtime": "t3-web", | ||
| 14 | "service.mode": isElectron ? "electron" : "browser", | ||
| 15 | "service.version": APP_VERSION, | ||
| 16 | }, | ||
| 17 | } as const; | ||
| 18 | |||
| 19 | const delegateRuntimeLayer = Layer.mergeAll( | ||
| 20 | FetchHttpClient.layer, | ||
| 21 | OtlpSerialization.layerJson, | ||
| 22 | Layer.succeed(HttpClient.TracerDisabledWhen, () => true), | ||
| 23 | ); | ||
| 24 | |||
| 25 | let activeDelegate: Tracer.Tracer | null = null; | ||
| 26 | let activeRuntime: ManagedRuntime.ManagedRuntime<never, never> | null = null; | ||
| 27 | let activeScope: Scope.Closeable | null = null; | ||
| 28 | let activeConfigKey: string | null = null; | ||
| 29 | let configurationGeneration = 0; | ||
| 30 | let pendingConfiguration = Promise.resolve(); | ||
| 31 | |||
| 32 | export interface ClientTracingConfig { | ||
| 33 | readonly exportIntervalMs?: number; | ||
| 34 | } | ||
| 35 | |||
| 36 | export const ClientTracingLive = Layer.succeed( | ||
| 37 | Tracer.Tracer, | ||
| 38 | Tracer.make({ | ||
| 39 | span(options) { | ||
| 40 | return activeDelegate?.span(options) ?? new Tracer.NativeSpan(options); | ||
| 41 | }, | ||
| 42 | }), | ||
| 43 | ); | ||
| 44 | |||
| 45 | export function configureClientTracing(config: ClientTracingConfig = {}): Promise<void> { | ||
| 46 | if (config.exportIntervalMs === undefined && activeConfigKey !== null) { | ||
| 47 | return pendingConfiguration; | ||
| 48 | } | ||
| 49 | pendingConfiguration = pendingConfiguration.finally(() => applyClientTracingConfig(config)); | ||
| 50 | return pendingConfiguration; | ||
| 51 | } | ||
| 52 | |||
| 53 | async function applyClientTracingConfig(config: ClientTracingConfig): Promise<void> { | ||
| 54 | const otlpTracesUrl = resolveServerUrl({ | ||
| 55 | protocol: window.location.protocol === "https:" ? "https" : "http", | ||
| 56 | pathname: "/api/observability/v1/traces", | ||
| 57 | }); | ||
| 58 | const exportIntervalMs = Math.max(10, config.exportIntervalMs ?? DEFAULT_EXPORT_INTERVAL_MS); | ||
| 59 | const nextConfigKey = `${otlpTracesUrl}|${exportIntervalMs}`; | ||
| 60 | |||
| 61 | if (activeConfigKey === nextConfigKey && activeDelegate !== null) { | ||
| 62 | return; | ||
| 63 | } | ||
| 64 | |||
| 65 | activeConfigKey = nextConfigKey; | ||
| 66 | const generation = ++configurationGeneration; | ||
| 67 | |||
| 68 | const previousRuntime = activeRuntime; | ||
| 69 | const previousScope = activeScope; | ||
| 70 | |||
| 71 | activeDelegate = null; | ||
| 72 | activeRuntime = null; | ||
| 73 | activeScope = null; | ||
| 74 | |||
| 75 | await disposeTracerRuntime(previousRuntime, previousScope); | ||
| 76 | |||
| 77 | const runtime = ManagedRuntime.make(delegateRuntimeLayer); | ||
| 78 | const scope = runtime.runSync(Scope.make()); | ||
| 79 | |||
| 80 | try { | ||
| 81 | const delegate = await runtime.runPromise( | ||
| 82 | Scope.provide(scope)( | ||
| 83 | OtlpTracer.make({ | ||
| 84 | url: otlpTracesUrl, | ||
| 85 | exportInterval: `${exportIntervalMs} millis`, | ||
| 86 | resource: CLIENT_TRACING_RESOURCE, | ||
| 87 | }), | ||
| 88 | ), | ||
| 89 | ); | ||
| 90 | |||
| 91 | if (generation !== configurationGeneration) { | ||
| 92 | await disposeTracerRuntime(runtime, scope); | ||
| 93 | return; | ||
| 94 | } | ||
| 95 | |||
| 96 | activeDelegate = delegate; | ||
| 97 | activeRuntime = runtime; | ||
| 98 | activeScope = scope; | ||
| 99 | } catch (error) { | ||
| 100 | await disposeTracerRuntime(runtime, scope); | ||
| 101 | |||
| 102 | if (generation === configurationGeneration) { | ||
| 103 | console.warn("Failed to configure client tracing exporter", { | ||
| 104 | error: formatError(error), | ||
| 105 | otlpTracesUrl, | ||
| 106 | }); | ||
| 107 | } | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | async function disposeTracerRuntime( | ||
| 112 | runtime: ManagedRuntime.ManagedRuntime<never, never> | null, | ||
| 113 | scope: Scope.Closeable | null, | ||
| 114 | ): Promise<void> { | ||
| 115 | if (runtime === null || scope === null) { | ||
| 116 | return; | ||
| 117 | } | ||
| 118 | |||
| 119 | await runtime | ||
| 120 | .runPromise(Scope.close(scope, Exit.void)) | ||
| 121 | .catch(() => undefined) | ||
| 122 | .finally(() => { | ||
| 123 | runtime.dispose(); | ||
| 124 | }); | ||
| 125 | } | ||
| 126 | |||
| 127 | function formatError(error: unknown): string { | ||
| 128 | if (error instanceof Error && error.message.trim().length > 0) { | ||
| 129 | return error.message; | ||
| 130 | } | ||
| 131 | |||
| 132 | return String(error); | ||
| 133 | } | ||
| 134 | |||
| 135 | export async function __resetClientTracingForTests() { | ||
| 136 | configurationGeneration++; | ||
| 137 | activeConfigKey = null; | ||
| 138 | activeDelegate = null; | ||
| 139 | pendingConfiguration = Promise.resolve(); | ||
| 140 | |||
| 141 | const runtime = activeRuntime; | ||
| 142 | const scope = activeScope; | ||
| 143 | activeRuntime = null; | ||
| 144 | activeScope = null; | ||
| 145 | |||
| 146 | await disposeTracerRuntime(runtime, scope); | ||
| 147 | } | ||
apps/web/src/rpc/client.test.ts+48| ... | @@ -2,6 +2,10 @@ import { DEFAULT_SERVER_SETTINGS, WS_METHODS } from "@t3tools/contracts"; | ... | @@ -2,6 +2,10 @@ import { DEFAULT_SERVER_SETTINGS, WS_METHODS } from "@t3tools/contracts"; |
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 3 | import { AsyncResult, AtomRegistry } from "effect/unstable/reactivity"; | 3 | import { AsyncResult, AtomRegistry } from "effect/unstable/reactivity"; |
| 4 | 4 | ||
| 5 | import { | ||
| 6 | configureClientTracing, | ||
| 7 | __resetClientTracingForTests, | ||
| 8 | } from "../observability/clientTracing"; | ||
| 5 | import { __resetWsRpcAtomClientForTests, runRpc, WsRpcAtomClient } from "./client"; | 9 | import { __resetWsRpcAtomClientForTests, runRpc, WsRpcAtomClient } from "./client"; |
| 6 | 10 | ||
| 7 | type WsEventType = "open" | "message" | "close" | "error"; | 11 | type WsEventType = "open" | "message" | "close" | "error"; |
| ... | @@ -66,6 +70,7 @@ class MockWebSocket { | ... | @@ -66,6 +70,7 @@ class MockWebSocket { |
| 66 | } | 70 | } |
| 67 | 71 | ||
| 68 | const originalWebSocket = globalThis.WebSocket; | 72 | const originalWebSocket = globalThis.WebSocket; |
| 73 | const originalFetch = globalThis.fetch; | ||
| 69 | 74 | ||
| 70 | function getSocket(): MockWebSocket { | 75 | function getSocket(): MockWebSocket { |
| 71 | const socket = sockets.at(-1); | 76 | const socket = sockets.at(-1); |
| ... | @@ -112,6 +117,7 @@ beforeEach(() => { | ... | @@ -112,6 +117,7 @@ beforeEach(() => { |
| 112 | afterEach(() => { | 117 | afterEach(() => { |
| 113 | __resetWsRpcAtomClientForTests(); | 118 | __resetWsRpcAtomClientForTests(); |
| 114 | globalThis.WebSocket = originalWebSocket; | 119 | globalThis.WebSocket = originalWebSocket; |
| 120 | globalThis.fetch = originalFetch; | ||
| 115 | vi.restoreAllMocks(); | 121 | vi.restoreAllMocks(); |
| 116 | }); | 122 | }); |
| 117 | 123 | ||
| ... | @@ -228,4 +234,46 @@ describe("WsRpcAtomClient", () => { | ... | @@ -228,4 +234,46 @@ describe("WsRpcAtomClient", () => { |
| 228 | release(); | 234 | release(); |
| 229 | registry.dispose(); | 235 | registry.dispose(); |
| 230 | }); | 236 | }); |
| 237 | |||
| 238 | it("attaches distributed trace ids when client OTLP tracing is enabled", async () => { | ||
| 239 | await configureClientTracing({ | ||
| 240 | exportIntervalMs: 10, | ||
| 241 | }); | ||
| 242 | |||
| 243 | const requestPromise = runRpc((client) => client(WS_METHODS.serverGetSettings, {})); | ||
| 244 | |||
| 245 | await waitFor(() => { | ||
| 246 | expect(sockets).toHaveLength(1); | ||
| 247 | }); | ||
| 248 | |||
| 249 | const socket = getSocket(); | ||
| 250 | socket.open(); | ||
| 251 | |||
| 252 | await waitFor(() => { | ||
| 253 | expect(socket.sent).toHaveLength(1); | ||
| 254 | }); | ||
| 255 | |||
| 256 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { | ||
| 257 | id: string; | ||
| 258 | spanId?: string; | ||
| 259 | tag: string; | ||
| 260 | traceId?: string; | ||
| 261 | }; | ||
| 262 | expect(requestMessage.tag).toBe(WS_METHODS.serverGetSettings); | ||
| 263 | expect(requestMessage.traceId).toMatch(/^[0-9a-f]{32}$/); | ||
| 264 | expect(requestMessage.spanId).toMatch(/^[0-9a-f]{16}$/); | ||
| 265 | |||
| 266 | socket.serverMessage( | ||
| 267 | JSON.stringify({ | ||
| 268 | _tag: "Exit", | ||
| 269 | requestId: requestMessage.id, | ||
| 270 | exit: { | ||
| 271 | _tag: "Success", | ||
| 272 | value: DEFAULT_SERVER_SETTINGS, | ||
| 273 | }, | ||
| 274 | }), | ||
| 275 | ); | ||
| 276 | |||
| 277 | await expect(requestPromise).resolves.toEqual(DEFAULT_SERVER_SETTINGS); | ||
| 278 | }); | ||
| 231 | }); | 279 | }); |
apps/web/src/rpc/client.ts+11-2| ... | @@ -2,6 +2,11 @@ import { WsRpcGroup } from "@t3tools/contracts"; | ... | @@ -2,6 +2,11 @@ import { WsRpcGroup } from "@t3tools/contracts"; |
| 2 | import { Effect, Layer, ManagedRuntime } from "effect"; | 2 | import { Effect, Layer, ManagedRuntime } from "effect"; |
| 3 | import { AtomRpc } from "effect/unstable/reactivity"; | 3 | import { AtomRpc } from "effect/unstable/reactivity"; |
| 4 | 4 | ||
| 5 | import { | ||
| 6 | __resetClientTracingForTests, | ||
| 7 | ClientTracingLive, | ||
| 8 | configureClientTracing, | ||
| 9 | } from "../observability/clientTracing"; | ||
| 5 | import { createWsRpcProtocolLayer } from "./protocol"; | 10 | import { createWsRpcProtocolLayer } from "./protocol"; |
| 6 | 11 | ||
| 7 | export class WsRpcAtomClient extends AtomRpc.Service<WsRpcAtomClient>()("WsRpcAtomClient", { | 12 | export class WsRpcAtomClient extends AtomRpc.Service<WsRpcAtomClient>()("WsRpcAtomClient", { |
| ... | @@ -16,18 +21,22 @@ function getRuntime() { | ... | @@ -16,18 +21,22 @@ function getRuntime() { |
| 16 | return sharedRuntime; | 21 | return sharedRuntime; |
| 17 | } | 22 | } |
| 18 | 23 | ||
| 19 | sharedRuntime = ManagedRuntime.make(WsRpcAtomClient.layer); | 24 | sharedRuntime = ManagedRuntime.make(Layer.mergeAll(WsRpcAtomClient.layer, ClientTracingLive)); |
| 20 | return sharedRuntime; | 25 | return sharedRuntime; |
| 21 | } | 26 | } |
| 22 | 27 | ||
| 23 | export function runRpc<TSuccess, TError = never>( | 28 | export function runRpc<TSuccess, TError = never>( |
| 24 | execute: (client: typeof WsRpcAtomClient.Service) => Effect.Effect<TSuccess, TError, never>, | 29 | execute: (client: typeof WsRpcAtomClient.Service) => Effect.Effect<TSuccess, TError, never>, |
| 25 | ): Promise<TSuccess> { | 30 | ): Promise<TSuccess> { |
| 26 | return getRuntime().runPromise(WsRpcAtomClient.use(execute)); | 31 | return configureClientTracing().then(() => { |
| 32 | const runtime = getRuntime(); | ||
| 33 | return runtime.runPromise(WsRpcAtomClient.use(execute)); | ||
| 34 | }); | ||
| 27 | } | 35 | } |
| 28 | 36 | ||
| 29 | export async function __resetWsRpcAtomClientForTests() { | 37 | export async function __resetWsRpcAtomClientForTests() { |
| 30 | const runtime = sharedRuntime; | 38 | const runtime = sharedRuntime; |
| 31 | sharedRuntime = null; | 39 | sharedRuntime = null; |
| 32 | await runtime?.dispose(); | 40 | await runtime?.dispose(); |
| 41 | await __resetClientTracingForTests(); | ||
| 33 | } | 42 | } |
apps/web/src/wsTransport.test.ts+50-1| ... | @@ -1,6 +1,10 @@ | ... | @@ -1,6 +1,10 @@ |
| 1 | import { WS_METHODS } from "@t3tools/contracts"; | 1 | import { DEFAULT_SERVER_SETTINGS, WS_METHODS } from "@t3tools/contracts"; |
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 3 | 3 | ||
| 4 | import { | ||
| 5 | __resetClientTracingForTests, | ||
| 6 | configureClientTracing, | ||
| 7 | } from "./observability/clientTracing"; | ||
| 4 | import { WsTransport } from "./wsTransport"; | 8 | import { WsTransport } from "./wsTransport"; |
| 5 | 9 | ||
| 6 | type WsEventType = "open" | "message" | "close" | "error"; | 10 | type WsEventType = "open" | "message" | "close" | "error"; |
| ... | @@ -63,6 +67,7 @@ class MockWebSocket { | ... | @@ -63,6 +67,7 @@ class MockWebSocket { |
| 63 | } | 67 | } |
| 64 | 68 | ||
| 65 | const originalWebSocket = globalThis.WebSocket; | 69 | const originalWebSocket = globalThis.WebSocket; |
| 70 | const originalFetch = globalThis.fetch; | ||
| 66 | 71 | ||
| 67 | function getSocket(): MockWebSocket { | 72 | function getSocket(): MockWebSocket { |
| 68 | const socket = sockets.at(-1); | 73 | const socket = sockets.at(-1); |
| ... | @@ -108,6 +113,8 @@ beforeEach(() => { | ... | @@ -108,6 +113,8 @@ beforeEach(() => { |
| 108 | 113 | ||
| 109 | afterEach(() => { | 114 | afterEach(() => { |
| 110 | globalThis.WebSocket = originalWebSocket; | 115 | globalThis.WebSocket = originalWebSocket; |
| 116 | globalThis.fetch = originalFetch; | ||
| 117 | void __resetClientTracingForTests(); | ||
| 111 | vi.restoreAllMocks(); | 118 | vi.restoreAllMocks(); |
| 112 | }); | 119 | }); |
| 113 | 120 | ||
| ... | @@ -440,4 +447,46 @@ describe("WsTransport", () => { | ... | @@ -440,4 +447,46 @@ describe("WsTransport", () => { |
| 440 | 447 | ||
| 441 | expect(callOrder).toEqual(["close:start", "close:done", "runtime:dispose"]); | 448 | expect(callOrder).toEqual(["close:start", "close:done", "runtime:dispose"]); |
| 442 | }); | 449 | }); |
| 450 | |||
| 451 | it("propagates OTLP trace ids for ws transport requests when client tracing is enabled", async () => { | ||
| 452 | await configureClientTracing({ | ||
| 453 | exportIntervalMs: 10, | ||
| 454 | }); | ||
| 455 | |||
| 456 | const transport = new WsTransport("ws://localhost:3020"); | ||
| 457 | const requestPromise = transport.request((client) => client[WS_METHODS.serverGetSettings]({})); | ||
| 458 | |||
| 459 | await waitFor(() => { | ||
| 460 | expect(sockets).toHaveLength(1); | ||
| 461 | }); | ||
| 462 | |||
| 463 | const socket = getSocket(); | ||
| 464 | socket.open(); | ||
| 465 | |||
| 466 | await waitFor(() => { | ||
| 467 | expect(socket.sent).toHaveLength(1); | ||
| 468 | }); | ||
| 469 | |||
| 470 | const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { | ||
| 471 | id: string; | ||
| 472 | spanId?: string; | ||
| 473 | traceId?: string; | ||
| 474 | }; | ||
| 475 | expect(requestMessage.traceId).toMatch(/^[0-9a-f]{32}$/); | ||
| 476 | expect(requestMessage.spanId).toMatch(/^[0-9a-f]{16}$/); | ||
| 477 | |||
| 478 | socket.serverMessage( | ||
| 479 | JSON.stringify({ | ||
| 480 | _tag: "Exit", | ||
| 481 | requestId: requestMessage.id, | ||
| 482 | exit: { | ||
| 483 | _tag: "Success", | ||
| 484 | value: DEFAULT_SERVER_SETTINGS, | ||
| 485 | }, | ||
| 486 | }), | ||
| 487 | ); | ||
| 488 | |||
| 489 | await expect(requestPromise).resolves.toEqual(DEFAULT_SERVER_SETTINGS); | ||
| 490 | await transport.dispose(); | ||
| 491 | }); | ||
| 443 | }); | 492 | }); |
apps/web/src/wsTransport.ts+11-3| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Duration, Effect, Exit, ManagedRuntime, Option, Scope, Stream } from "effect"; | 1 | import { Duration, Effect, Exit, Layer, ManagedRuntime, Option, Scope, Stream } from "effect"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | createWsRpcProtocolLayer, | 4 | createWsRpcProtocolLayer, |
| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | type WsRpcProtocolClient, | 6 | type WsRpcProtocolClient, |
| 7 | } from "./rpc/protocol"; | 7 | } from "./rpc/protocol"; |
| 8 | import { RpcClient } from "effect/unstable/rpc"; | 8 | import { RpcClient } from "effect/unstable/rpc"; |
| 9 | import { ClientTracingLive, configureClientTracing } from "./observability/clientTracing"; | ||
| 9 | 10 | ||
| 10 | interface SubscribeOptions { | 11 | interface SubscribeOptions { |
| 11 | readonly retryDelay?: Duration.Input; | 12 | readonly retryDelay?: Duration.Input; |
| ... | @@ -28,10 +29,14 @@ export class WsTransport { | ... | @@ -28,10 +29,14 @@ export class WsTransport { |
| 28 | private readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>; | 29 | private readonly runtime: ManagedRuntime.ManagedRuntime<RpcClient.Protocol, never>; |
| 29 | private readonly clientScope: Scope.Closeable; | 30 | private readonly clientScope: Scope.Closeable; |
| 30 | private readonly clientPromise: Promise<WsRpcProtocolClient>; | 31 | private readonly clientPromise: Promise<WsRpcProtocolClient>; |
| 32 | private readonly tracingReady: Promise<void>; | ||
| 31 | private disposed = false; | 33 | private disposed = false; |
| 32 | 34 | ||
| 33 | constructor(url?: string) { | 35 | constructor(url?: string) { |
| 34 | this.runtime = ManagedRuntime.make(createWsRpcProtocolLayer(url)); | 36 | this.tracingReady = configureClientTracing(); |
| 37 | this.runtime = ManagedRuntime.make( | ||
| 38 | Layer.mergeAll(createWsRpcProtocolLayer(url), ClientTracingLive), | ||
| 39 | ); | ||
| 35 | this.clientScope = this.runtime.runSync(Scope.make()); | 40 | this.clientScope = this.runtime.runSync(Scope.make()); |
| 36 | this.clientPromise = this.runtime.runPromise( | 41 | this.clientPromise = this.runtime.runPromise( |
| 37 | Scope.provide(this.clientScope)(makeWsRpcProtocolClient), | 42 | Scope.provide(this.clientScope)(makeWsRpcProtocolClient), |
| ... | @@ -46,6 +51,7 @@ export class WsTransport { | ... | @@ -46,6 +51,7 @@ export class WsTransport { |
| 46 | throw new Error("Transport disposed"); | 51 | throw new Error("Transport disposed"); |
| 47 | } | 52 | } |
| 48 | 53 | ||
| 54 | await this.tracingReady; | ||
| 49 | const client = await this.clientPromise; | 55 | const client = await this.clientPromise; |
| 50 | return await this.runtime.runPromise(Effect.suspend(() => execute(client))); | 56 | return await this.runtime.runPromise(Effect.suspend(() => execute(client))); |
| 51 | } | 57 | } |
| ... | @@ -58,6 +64,7 @@ export class WsTransport { | ... | @@ -58,6 +64,7 @@ export class WsTransport { |
| 58 | throw new Error("Transport disposed"); | 64 | throw new Error("Transport disposed"); |
| 59 | } | 65 | } |
| 60 | 66 | ||
| 67 | await this.tracingReady; | ||
| 61 | const client = await this.clientPromise; | 68 | const client = await this.clientPromise; |
| 62 | await this.runtime.runPromise( | 69 | await this.runtime.runPromise( |
| 63 | Stream.runForEach(connect(client), (value) => | 70 | Stream.runForEach(connect(client), (value) => |
| ... | @@ -84,7 +91,8 @@ export class WsTransport { | ... | @@ -84,7 +91,8 @@ export class WsTransport { |
| 84 | let active = true; | 91 | let active = true; |
| 85 | const retryDelayMs = options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS; | 92 | const retryDelayMs = options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY_MS; |
| 86 | const cancel = this.runtime.runCallback( | 93 | const cancel = this.runtime.runCallback( |
| 87 | Effect.promise(() => this.clientPromise).pipe( | 94 | Effect.promise(() => this.tracingReady).pipe( |
| 95 | Effect.flatMap(() => Effect.promise(() => this.clientPromise)), | ||
| 88 | Effect.flatMap((client) => | 96 | Effect.flatMap((client) => |
| 89 | Stream.runForEach(connect(client), (value) => | 97 | Stream.runForEach(connect(client), (value) => |
| 90 | Effect.sync(() => { | 98 | Effect.sync(() => { |