Skip to content
Source
Sign inSign up
Open History to see the last commit

relayTracing.ts

183 lines5.6 KB

1import * as Cause from "effect/Cause";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Tracer from "effect/Tracer";
8import type { HttpClient } from "effect/http";
9import { OtlpSerialization, OtlpTracer } from "effect/observability";
10​
11export interface RelayClientTracingConfig {
12 readonly tracesUrl: string;
13 readonly tracesDataset: string;
14 readonly tracesToken: string;
16​
17export interface RelayClientTracingResource {
18 readonly serviceName: string;
19 readonly serviceVersion?: string;
20 readonly runtime: string;
21 readonly serviceInstanceId: string;
22 readonly client: string;
23 readonly component?: string;
24 readonly attributes?: Readonly<Record<string, string>> | undefined;
26​
27export class RelayClientTracer extends Context.Reference(
28 "@t3tools/shared/relayTracing/RelayClientTracer",
29 {
30 defaultValue: () => Option.none<Tracer.Tracer>(),
31 },
32) {}
33​
34class LocalTracer extends Context.Reference("@t3tools/shared/relayTracing/LocalTracer", {
35 defaultValue: () => Option.none<Tracer.Tracer>(),
36}) {}
37​
38/** Export only T3 Connect spans; wrap local work with {@link withLocalTracing}. */
39export const withRelayClientTracing = <A, E, R>(
40 effect: Effect.Effect<A, E, R>,
41): Effect.Effect<A, E, R> =>
42 RelayClientTracer.pipe(
43 Effect.flatMap(
44 Option.match({
45 onNone: () => effect,
46 onSome: (tracer) =>
47 Tracer.Tracer.pipe(
48 Effect.flatMap((local) =>
49 effect.pipe(
50 Effect.provideService(Tracer.Tracer, tracer),
51 Effect.provideService(LocalTracer, Option.some(local)),
52 ),
53 ),
54 ),
55 }),
56 ),
57 );
58​
59/** Keep local work in the local trace without exporting machine details through relay spans. */
60export const withLocalTracing = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
61 LocalTracer.pipe(
62 Effect.flatMap(
63 Option.match({
64 onNone: () => effect,
65 onSome: (local) => effect.pipe(Effect.provideService(Tracer.Tracer, local)),
66 }),
67 ),
68 );
69​
70function cleanTraceStack(error: Error): string {
71 const stack = error.stack ?? `${error.name}: ${error.message}`;
72 const lines = stack.split("\n");
73 const effectFrameIndex = lines.findIndex(
74 (line, index) => index > 0 && /(?:Generator\.next|~effect\/Effect)/.test(line),
75 );
76 return effectFrameIndex < 0 ? stack : lines.slice(0, effectFrameIndex).join("\n");
78​
79function traceSafeError(value: unknown, seen = new WeakSet<object>()): Error {
80 const message =
81 value instanceof Error
82 ? value.message
83 : typeof value === "object" &&
84 value !== null &&
85 "message" in value &&
86 typeof value.message === "string"
87 ? value.message
88 : String(value);
89​
90 let cause: Error | undefined;
91 if (typeof value === "object" && value !== null && !seen.has(value)) {
92 seen.add(value);
93 if ("cause" in value && value.cause !== undefined) {
94 cause = traceSafeError(value.cause, seen);
95 }
96 }
97​
98 const error = new Error(message, cause ? { cause } : undefined);
99 if (value instanceof Error) {
100 error.name = value.name;
101 error.stack = cleanTraceStack(value);
102 } else if (
103 typeof value === "object" &&
104 value !== null &&
105 "name" in value &&
106 typeof value.name === "string"
107 ) {
108 error.name = value.name;
109 }
110 if (cause) {
111 error.stack = `${error.stack ?? `${error.name}: ${error.message}`}\nCaused by: ${cause.stack ?? `${cause.name}: ${cause.message}`}`;
112 }
113 return error;
115​
116function traceSafeExit(exit: Exit.Exit<unknown, unknown>): Exit.Exit<unknown, unknown> {
117 if (Exit.isSuccess(exit)) {
118 return exit;
119 }
120 return Exit.failCause(
121 Cause.fromReasons(
122 exit.cause.reasons.map((reason) => {
123 if (Cause.isFailReason(reason)) {
124 return Cause.makeFailReason(traceSafeError(reason.error));
125 }
126 if (Cause.isDieReason(reason)) {
127 return Cause.makeDieReason(traceSafeError(reason.defect));
128 }
129 return reason;
130 }),
131 ),
132 );
134​
135function nonInterferingTracer(delegate: Tracer.Tracer): Tracer.Tracer {
136 return Tracer.make({
137 span(options) {
138 const span = delegate.span(options);
139 const end = span.end.bind(span);
140 span.end = (endTime, exit) => {
141 try {
142 end(endTime, traceSafeExit(exit));
143 } catch {}
144 };
145 return span;
146 },
147 ...(delegate.context ? { context: delegate.context } : {}),
148 });
150​
151export function layer(
152 config: RelayClientTracingConfig | null,
153 resource: RelayClientTracingResource,
154): Layer.Layer<never, never, HttpClient.HttpClient> {
155 if (config === null) {
156 return Layer.succeed(RelayClientTracer, Option.none());
157 }
158​
159 const layerTracer = OtlpTracer.layer({
160 url: config.tracesUrl,
161 headers: {
162 Authorization: `Bearer ${config.tracesToken}`,
163 "X-Axiom-Dataset": config.tracesDataset,
164 },
165 resource: {
166 serviceName: resource.serviceName,
167 serviceVersion: resource.serviceVersion,
168 attributes: {
169 "service.namespace": "t3code",
170 "service.instance.id": resource.serviceInstanceId,
171 "service.runtime": resource.runtime,
172 "t3code.component": resource.component ?? "relay-client",
173 "t3code.client.surface": resource.client,
174 ...resource.attributes,
175 },
176 },
177 }).pipe(Layer.provide(OtlpSerialization.layerJson));
178​
179 return Layer.effect(
180 RelayClientTracer,
181 Tracer.Tracer.pipe(Effect.map(nonInterferingTracer), Effect.asSome),
182 ).pipe(Layer.provide(layerTracer));