75 lines · 3.1 KB · TypeScript
RawDownload
// Telemetry surface: query the suite's motel OTLP store for the spans the// target ACTUALLY exported. This is how a scenario asserts the observability// contract end-to-end — not "did the code create a span object" but "did the// span leave the server, with the attributes production queries depend on".// The dangerous regression mode here is silence (an attribute stamped on the// wrong span, an error riding the success channel): absent data looks exactly// like health, so the contract has to be pinned where the data is read.import { Effect, Schedule } from "effect"; /** One exported span, as motel's /api/spans/search returns it. `tags` is the *  span's attributes with every value stringified. */export interface ExportedSpan {  readonly traceId: string;  readonly rootOperationName: string;  readonly span: {    readonly spanId: string;    readonly operationName: string;    readonly status: "ok" | "error";    readonly durationMs: number;    readonly tags: Readonly<Record<string, string>>;  };} export interface SpanQuery {  /** Exact-match attribute filters (motel `attr.<key>=<value>`). */  readonly attributes?: Readonly<Record<string, string>>;  readonly operation?: string;  readonly traceId?: string;} export interface TelemetrySurface {  /** One-shot search against the trace store. */  readonly searchSpans: (query: SpanQuery) => Effect.Effect<readonly ExportedSpan[], unknown>;  /** Search until at least one span matches. Exporters batch (the app   *  flushes ~1s after the request), so arrival is eventually-consistent —   *  polling IS the contract: "the span reaches the store, soon". */  readonly expectSpan: (query: SpanQuery) => Effect.Effect<ExportedSpan, unknown>;} export const makeTelemetrySurface = (motelUrl: string): TelemetrySurface => {  const searchSpans = (query: SpanQuery) =>    Effect.gen(function* () {      const params = new URLSearchParams({ lookback: "15m", limit: "100" });      if (query.operation) params.set("operation", query.operation);      if (query.traceId) params.set("traceId", query.traceId);      for (const [key, value] of Object.entries(query.attributes ?? {})) {        params.set(`attr.${key}`, value);      }      const response = yield* Effect.promise(() => fetch(`${motelUrl}/api/spans/search?${params}`));      if (!response.ok) {        return yield* Effect.fail(          `motel span search responded ${response.status}: ${yield* Effect.promise(() => response.text())}`,        );      }      const body = (yield* Effect.promise(() => response.json())) as {        readonly data?: readonly ExportedSpan[];      };      return body.data ?? [];    });   return {    searchSpans,    expectSpan: (query) =>      searchSpans(query).pipe(        Effect.filterOrFail(          (spans) => spans.length > 0,          () => `no exported span matched ${JSON.stringify(query)}`,        ),        Effect.map((spans) => spans[0]!),        // ~20s ceiling (40 × 500ms): BatchSpanProcessor flushes ~1s after the        // request and the dev stack drains on waitUntil; slower is a real bug.        Effect.retry(Schedule.both(Schedule.spaced("500 millis"), Schedule.recurs(40))),      ),  };};