authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-19 21:29:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-19 21:29:25-07:00
logccf8331600f323939a796169dfe2598a4c1b59c4
tree2c6d22cad818c99435435ec9f584e2daa700ac77
parentffae12f329c4de9680efd1757b488a3b5bbe3cd2
signature Signed by PGP key B5690EEEBB952194

[codex] Align diagnostics and telemetry Effect services (#3189)

Co-authored-by: codex <codex@users.noreply.github.com>

20 files changed, 718 insertions(+), 556 deletions(-)

apps/server/src/diagnostics/ProcessDiagnostics.ts+103-40
......@@ -12,7 +12,8 @@ import * as Effect from "effect/Effect";
1212import * as Layer from "effect/Layer";
1313import * as Option from "effect/Option";
1414import * as Schema from "effect/Schema";
15import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
15import * as ChildProcess from "effect/unstable/process/ChildProcess";
16import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1617
1718import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
1819
......@@ -31,35 +32,80 @@ const PROCESS_QUERY_TIMEOUT_MS = 1_000;
3132const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=";
3233const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
3334
34export interface ProcessDiagnosticsShape {
35 readonly read: Effect.Effect<ServerProcessDiagnosticsResult>;
36 readonly signal: (input: {
37 readonly pid: number;
38 readonly signal: ServerProcessSignal;
39 }) => Effect.Effect<ServerSignalProcessResult>;
40}
41
4235export class ProcessDiagnostics extends Context.Service<
4336 ProcessDiagnostics,
44 ProcessDiagnosticsShape
37 {
38 readonly read: Effect.Effect<ServerProcessDiagnosticsResult>;
39 readonly signal: (input: {
40 readonly pid: number;
41 readonly signal: ServerProcessSignal;
42 }) => Effect.Effect<ServerSignalProcessResult>;
43 }
4544>()("t3/diagnostics/ProcessDiagnostics") {}
4645
47class ProcessDiagnosticsError extends Schema.TaggedErrorClass<ProcessDiagnosticsError>()(
48 "ProcessDiagnosticsError",
46class ProcessDiagnosticsQueryTimeoutError extends Schema.TaggedErrorClass<ProcessDiagnosticsQueryTimeoutError>()(
47 "ProcessDiagnosticsQueryTimeoutError",
48 { command: Schema.String },
49) {
50 override get message(): string {
51 return `Process diagnostics query '${this.command}' timed out.`;
52 }
53}
54
55class ProcessDiagnosticsQueryFailedError extends Schema.TaggedErrorClass<ProcessDiagnosticsQueryFailedError>()(
56 "ProcessDiagnosticsQueryFailedError",
4957 {
50 message: Schema.String,
58 command: Schema.String,
59 stderr: Schema.optional(Schema.String),
5160 cause: Schema.optional(Schema.Defect()),
5261 },
53) {}
54const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);
62) {
63 override get message(): string {
64 return this.stderr?.trim() || `Failed to query process diagnostics with '${this.command}'.`;
65 }
66}
5567
56function toProcessDiagnosticsError(message: string, cause?: unknown): ProcessDiagnosticsError {
57 return new ProcessDiagnosticsError({
58 message,
59 ...(cause === undefined ? {} : { cause }),
60 });
68class ProcessDiagnosticsServerProcessSignalError extends Schema.TaggedErrorClass<ProcessDiagnosticsServerProcessSignalError>()(
69 "ProcessDiagnosticsServerProcessSignalError",
70 { pid: Schema.Number },
71) {
72 override get message(): string {
73 return "Refusing to signal the T3 server process.";
74 }
75}
76
77class ProcessDiagnosticsNotDescendantError extends Schema.TaggedErrorClass<ProcessDiagnosticsNotDescendantError>()(
78 "ProcessDiagnosticsNotDescendantError",
79 { pid: Schema.Number },
80) {
81 override get message(): string {
82 return `Process ${this.pid} is not a live descendant of the T3 server.`;
83 }
6184}
6285
86class ProcessDiagnosticsSignalFailedError extends Schema.TaggedErrorClass<ProcessDiagnosticsSignalFailedError>()(
87 "ProcessDiagnosticsSignalFailedError",
88 {
89 pid: Schema.Number,
90 signal: Schema.String,
91 cause: Schema.Defect(),
92 },
93) {
94 override get message(): string {
95 return `Failed to signal process ${this.pid} with ${this.signal}.`;
96 }
97}
98
99const ProcessDiagnosticsError = Schema.Union([
100 ProcessDiagnosticsQueryTimeoutError,
101 ProcessDiagnosticsQueryFailedError,
102 ProcessDiagnosticsServerProcessSignalError,
103 ProcessDiagnosticsNotDescendantError,
104 ProcessDiagnosticsSignalFailedError,
105]);
106type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type;
107const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);
108
63109function parsePositiveInt(value: string): number | null {
64110 const parsed = Number.parseInt(value, 10);
65111 return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
......@@ -272,11 +318,7 @@ interface ProcessOutput {
272318}
273319
274320const runProcess = Effect.fn("runProcess")(
275 function* (input: {
276 readonly command: string;
277 readonly args: ReadonlyArray<string>;
278 readonly errorMessage: string;
279 }) {
321 function* (input: { readonly command: string; readonly args: ReadonlyArray<string> }) {
280322 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
281323 // `ps` and `powershell.exe` are real executables; spawning through cmd.exe
282324 // shell mode would re-tokenize the PowerShell `-Command` payload (which
......@@ -315,14 +357,22 @@ const runProcess = Effect.fn("runProcess")(
315357 Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)),
316358 Effect.flatMap((result) =>
317359 Option.match(result, {
318 onNone: () => Effect.fail(toProcessDiagnosticsError(`${input.errorMessage} timed out.`)),
360 onNone: () =>
361 Effect.fail(
362 new ProcessDiagnosticsQueryTimeoutError({
363 command: input.command,
364 }),
365 ),
319366 onSome: Effect.succeed,
320367 }),
321368 ),
322369 Effect.mapError((cause) =>
323370 isProcessDiagnosticsError(cause)
324371 ? cause
325 : toProcessDiagnosticsError(input.errorMessage, cause),
372 : new ProcessDiagnosticsQueryFailedError({
373 command: input.command,
374 cause,
375 }),
326376 ),
327377 ),
328378);
......@@ -335,11 +385,15 @@ function readPosixProcessRows(): Effect.Effect<
335385 return runProcess({
336386 command: "ps",
337387 args: ["-axo", POSIX_PROCESS_QUERY_COMMAND],
338 errorMessage: "Failed to query process diagnostics.",
339388 }).pipe(
340389 Effect.flatMap((result) =>
341390 result.exitCode !== 0
342 ? Effect.fail(toProcessDiagnosticsError(result.stderr.trim() || "ps failed."))
391 ? Effect.fail(
392 new ProcessDiagnosticsQueryFailedError({
393 command: "ps",
394 stderr: result.stderr.trim() || "ps failed.",
395 }),
396 )
343397 : Effect.succeed(parsePosixProcessRows(result.stdout)),
344398 ),
345399 );
......@@ -361,12 +415,14 @@ function readWindowsProcessRows(): Effect.Effect<
361415 return runProcess({
362416 command: "powershell.exe",
363417 args: ["-NoProfile", "-NonInteractive", "-Command", command],
364 errorMessage: "Failed to query process diagnostics.",
365418 }).pipe(
366419 Effect.flatMap((result) =>
367420 result.exitCode !== 0
368421 ? Effect.fail(
369 toProcessDiagnosticsError(result.stderr.trim() || "PowerShell process query failed."),
422 new ProcessDiagnosticsQueryFailedError({
423 command: "powershell.exe",
424 stderr: result.stderr.trim() || "PowerShell process query failed.",
425 }),
370426 )
371427 : Effect.succeed(parseWindowsProcessRows(result.stdout)),
372428 ),
......@@ -390,7 +446,11 @@ function assertDescendantPid(
390446 pid: number,
391447): Effect.Effect<void, ProcessDiagnosticsError, ChildProcessSpawner.ChildProcessSpawner> {
392448 if (pid === process.pid) {
393 return Effect.fail(toProcessDiagnosticsError("Refusing to signal the T3 server process."));
449 return Effect.fail(
450 new ProcessDiagnosticsServerProcessSignalError({
451 pid,
452 }),
453 );
394454 }
395455
396456 return readProcessRows.pipe(
......@@ -402,16 +462,18 @@ function assertDescendantPid(
402462 return descendant
403463 ? Effect.void
404464 : Effect.fail(
405 toProcessDiagnosticsError(`Process ${pid} is not a live descendant of the T3 server.`),
465 new ProcessDiagnosticsNotDescendantError({
466 pid,
467 }),
406468 );
407469 }),
408470 );
409471}
410472
411export const make = Effect.fn("makeProcessDiagnostics")(function* () {
473export const make = Effect.gen(function* () {
412474 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
413475
414 const read: ProcessDiagnosticsShape["read"] = Effect.gen(function* () {
476 const read: ProcessDiagnostics["Service"]["read"] = Effect.gen(function* () {
415477 const readAt = yield* DateTime.now;
416478 const rows = yield* readProcessRows.pipe(
417479 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
......@@ -427,7 +489,7 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () {
427489 ),
428490 );
429491
430 const signal: ProcessDiagnosticsShape["signal"] = Effect.fn("ProcessDiagnostics.signal")(
492 const signal: ProcessDiagnostics["Service"]["signal"] = Effect.fn("ProcessDiagnostics.signal")(
431493 function* (input) {
432494 return yield* assertDescendantPid(input.pid).pipe(
433495 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
......@@ -443,10 +505,11 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () {
443505 };
444506 },
445507 catch: (cause) =>
446 toProcessDiagnosticsError(
447 `Failed to signal process ${input.pid} with ${input.signal}.`,
508 new ProcessDiagnosticsSignalFailedError({
509 pid: input.pid,
510 signal: input.signal,
448511 cause,
449 ),
512 }),
450513 }),
451514 ),
452515 Effect.catch((error: ProcessDiagnosticsError) =>
......@@ -464,4 +527,4 @@ export const make = Effect.fn("makeProcessDiagnostics")(function* () {
464527 return ProcessDiagnostics.of({ read, signal });
465528});
466529
467export const layer = Layer.effect(ProcessDiagnostics, make());
530export const layer = Layer.effect(ProcessDiagnostics, make);
apps/server/src/diagnostics/ProcessResourceMonitor.test.ts+10-13
......@@ -3,16 +3,13 @@ import * as DateTime from "effect/DateTime";
33import * as Effect from "effect/Effect";
44import * as Option from "effect/Option";
55
6import {
7 aggregateProcessResourceHistory,
8 collectMonitoredSamples,
9} from "./ProcessResourceMonitor.ts";
6import * as ProcessResourceMonitor from "./ProcessResourceMonitor.ts";
107
118describe("ProcessResourceMonitor", () => {
129 it.effect("samples the server root process and descendants", () =>
1310 Effect.sync(() => {
1411 const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
15 const samples = collectMonitoredSamples({
12 const samples = ProcessResourceMonitor.collectMonitoredSamples({
1613 serverPid: 100,
1714 sampledAt,
1815 sampledAtMs: DateTime.toEpochMillis(sampledAt),
......@@ -72,7 +69,7 @@ describe("ProcessResourceMonitor", () => {
7269 const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
7370 const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.000Z");
7471 const samples = [
75 ...collectMonitoredSamples({
72 ...ProcessResourceMonitor.collectMonitoredSamples({
7673 serverPid: 100,
7774 sampledAt: firstAt,
7875 sampledAtMs: DateTime.toEpochMillis(firstAt),
......@@ -89,7 +86,7 @@ describe("ProcessResourceMonitor", () => {
8986 },
9087 ],
9188 }),
92 ...collectMonitoredSamples({
89 ...ProcessResourceMonitor.collectMonitoredSamples({
9390 serverPid: 100,
9491 sampledAt: secondAt,
9592 sampledAtMs: DateTime.toEpochMillis(secondAt),
......@@ -108,7 +105,7 @@ describe("ProcessResourceMonitor", () => {
108105 }),
109106 ];
110107
111 const result = aggregateProcessResourceHistory({
108 const result = ProcessResourceMonitor.aggregateProcessResourceHistory({
112109 samples,
113110 readAt: secondAt,
114111 readAtMs: DateTime.toEpochMillis(secondAt),
......@@ -132,7 +129,7 @@ describe("ProcessResourceMonitor", () => {
132129 const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.400Z");
133130 const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.900Z");
134131 const samples = [
135 ...collectMonitoredSamples({
132 ...ProcessResourceMonitor.collectMonitoredSamples({
136133 serverPid: 100,
137134 sampledAt: firstAt,
138135 sampledAtMs: DateTime.toEpochMillis(firstAt),
......@@ -149,7 +146,7 @@ describe("ProcessResourceMonitor", () => {
149146 },
150147 ],
151148 }),
152 ...collectMonitoredSamples({
149 ...ProcessResourceMonitor.collectMonitoredSamples({
153150 serverPid: 100,
154151 sampledAt: secondAt,
155152 sampledAtMs: DateTime.toEpochMillis(secondAt),
......@@ -168,7 +165,7 @@ describe("ProcessResourceMonitor", () => {
168165 }),
169166 ];
170167
171 const result = aggregateProcessResourceHistory({
168 const result = ProcessResourceMonitor.aggregateProcessResourceHistory({
172169 samples,
173170 readAt: secondAt,
174171 readAtMs: DateTime.toEpochMillis(secondAt),
......@@ -187,7 +184,7 @@ describe("ProcessResourceMonitor", () => {
187184 it.effect("returns all process summaries in the selected window", () =>
188185 Effect.sync(() => {
189186 const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
190 const samples = collectMonitoredSamples({
187 const samples = ProcessResourceMonitor.collectMonitoredSamples({
191188 serverPid: 100,
192189 sampledAt,
193190 sampledAtMs: DateTime.toEpochMillis(sampledAt),
......@@ -215,7 +212,7 @@ describe("ProcessResourceMonitor", () => {
215212 ],
216213 });
217214
218 const result = aggregateProcessResourceHistory({
215 const result = ProcessResourceMonitor.aggregateProcessResourceHistory({
219216 samples,
220217 readAt: sampledAt,
221218 readAtMs: DateTime.toEpochMillis(sampledAt),
apps/server/src/diagnostics/ProcessResourceMonitor.ts+21-23
......@@ -10,14 +10,9 @@ import * as Effect from "effect/Effect";
1010import * as Layer from "effect/Layer";
1111import * as Option from "effect/Option";
1212import * as Ref from "effect/Ref";
13import { ChildProcessSpawner } from "effect/unstable/process";
13import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1414
15import {
16 buildDescendantEntries,
17 isDiagnosticsQueryProcess,
18 type ProcessRow,
19 readProcessRows,
20} from "./ProcessDiagnostics.ts";
15import * as ProcessDiagnostics from "./ProcessDiagnostics.ts";
2116
2217const SAMPLE_INTERVAL_MS = 5_000;
2318const RETENTION_MS = 60 * 60_000;
......@@ -41,38 +36,41 @@ interface MonitorState {
4136 readonly lastError: string | null;
4237}
4338
44export interface ProcessResourceMonitorShape {
45 readonly readHistory: (
46 input: ServerProcessResourceHistoryInput,
47 ) => Effect.Effect<ServerProcessResourceHistoryResult>;
48}
49
5039export class ProcessResourceMonitor extends Context.Service<
5140 ProcessResourceMonitor,
52 ProcessResourceMonitorShape
41 {
42 readonly readHistory: (
43 input: ServerProcessResourceHistoryInput,
44 ) => Effect.Effect<ServerProcessResourceHistoryResult>;
45 }
5346>()("t3/diagnostics/ProcessResourceMonitor") {}
5447
5548function dateTimeFromMillis(ms: number): DateTime.Utc {
5649 return DateTime.makeUnsafe(ms);
5750}
5851
59function sampleKey(row: Pick<ProcessRow, "pid" | "command">): string {
52function sampleKey(row: Pick<ProcessDiagnostics.ProcessRow, "pid" | "command">): string {
6053 return `${row.pid}:${row.command}`;
6154}
6255
63function findServerRootRow(rows: ReadonlyArray<ProcessRow>, serverPid: number): ProcessRow | null {
56function findServerRootRow(
57 rows: ReadonlyArray<ProcessDiagnostics.ProcessRow>,
58 serverPid: number,
59): ProcessDiagnostics.ProcessRow | null {
6460 return rows.find((row) => row.pid === serverPid) ?? null;
6561}
6662
6763export function collectMonitoredSamples(input: {
68 readonly rows: ReadonlyArray<ProcessRow>;
64 readonly rows: ReadonlyArray<ProcessDiagnostics.ProcessRow>;
6965 readonly serverPid: number;
7066 readonly sampledAt: DateTime.Utc;
7167 readonly sampledAtMs: number;
7268}): ReadonlyArray<ProcessResourceSample> {
73 const rows = input.rows.filter((row) => !isDiagnosticsQueryProcess(row, input.serverPid));
69 const rows = input.rows.filter(
70 (row) => !ProcessDiagnostics.isDiagnosticsQueryProcess(row, input.serverPid),
71 );
7472 const root = findServerRootRow(rows, input.serverPid);
75 const descendants = buildDescendantEntries(rows, input.serverPid);
73 const descendants = ProcessDiagnostics.buildDescendantEntries(rows, input.serverPid);
7674 const samples: ProcessResourceSample[] = [];
7775
7876 if (root) {
......@@ -245,14 +243,14 @@ export function aggregateProcessResourceHistory(input: {
245243 };
246244}
247245
248export const make = Effect.fn("makeProcessResourceMonitor")(function* () {
246export const make = Effect.gen(function* () {
249247 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
250248 const state = yield* Ref.make<MonitorState>({ samples: [], lastError: null });
251249
252250 const sampleOnce = Effect.gen(function* () {
253251 const sampledAt = yield* DateTime.now;
254252 const sampledAtMs = DateTime.toEpochMillis(sampledAt);
255 const rows = yield* readProcessRows.pipe(
253 const rows = yield* ProcessDiagnostics.readProcessRows.pipe(
256254 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
257255 );
258256 const samples = collectMonitoredSamples({
......@@ -278,7 +276,7 @@ export const make = Effect.fn("makeProcessResourceMonitor")(function* () {
278276 Effect.forkScoped,
279277 );
280278
281 const readHistory: ProcessResourceMonitorShape["readHistory"] = (input) =>
279 const readHistory: ProcessResourceMonitor["Service"]["readHistory"] = (input) =>
282280 Effect.gen(function* () {
283281 const readAt = yield* DateTime.now;
284282 const readAtMs = DateTime.toEpochMillis(readAt);
......@@ -296,4 +294,4 @@ export const make = Effect.fn("makeProcessResourceMonitor")(function* () {
296294 return ProcessResourceMonitor.of({ readHistory });
297295});
298296
299export const layer = Layer.effect(ProcessResourceMonitor, make());
297export const layer = Layer.effect(ProcessResourceMonitor, make);
apps/server/src/diagnostics/TraceDiagnostics.ts+11-10
......@@ -39,13 +39,14 @@ export interface TraceDiagnosticsOptions {
3939 readonly readAt?: DateTime.Utc;
4040}
4141
42export interface TraceDiagnosticsShape {
43 readonly read: (options: TraceDiagnosticsOptions) => Effect.Effect<ServerTraceDiagnosticsResult>;
44}
45
46export class TraceDiagnostics extends Context.Service<TraceDiagnostics, TraceDiagnosticsShape>()(
47 "t3/diagnostics/TraceDiagnostics",
48) {}
42export class TraceDiagnostics extends Context.Service<
43 TraceDiagnostics,
44 {
45 readonly read: (
46 options: TraceDiagnosticsOptions,
47 ) => Effect.Effect<ServerTraceDiagnosticsResult>;
48 }
49>()("t3/diagnostics/TraceDiagnostics") {}
4950
5051interface TraceDiagnosticsInput {
5152 readonly traceFilePath: string;
......@@ -395,10 +396,10 @@ function readTraceFile(
395396 );
396397}
397398
398export const make = Effect.fn("makeTraceDiagnostics")(function* () {
399export const make = Effect.gen(function* () {
399400 const fileSystem = yield* FileSystem.FileSystem;
400401
401 const read: TraceDiagnosticsShape["read"] = Effect.fn("TraceDiagnostics.read")(
402 const read: TraceDiagnostics["Service"]["read"] = Effect.fn("TraceDiagnostics.read")(
402403 function* (options) {
403404 const readAt = options.readAt ?? (yield* DateTime.now);
404405 const slowSpanThresholdMs = options.slowSpanThresholdMs ?? DEFAULT_SLOW_SPAN_THRESHOLD_MS;
......@@ -449,7 +450,7 @@ export const make = Effect.fn("makeTraceDiagnostics")(function* () {
449450 return TraceDiagnostics.of({ read });
450451});
451452
452export const layer = Layer.effect(TraceDiagnostics, make());
453export const layer = Layer.effect(TraceDiagnostics, make);
453454
454455export function readTraceDiagnostics(
455456 options: TraceDiagnosticsOptions,
apps/server/src/http.ts+10-9
......@@ -24,13 +24,13 @@ import {
2424import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
2525import { OtlpTracer } from "effect/unstable/observability";
2626
27import { resolveStaticDir, ServerConfig } from "./config.ts";
27import * as ServerConfig from "./config.ts";
2828import {
2929 ASSET_ROUTE_PREFIX,
3030 FALLBACK_PROJECT_FAVICON_SVG,
3131 resolveAsset,
3232} from "./assets/AssetAccess.ts";
33import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts";
33import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
3434import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
3535import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";
3636import {
......@@ -39,7 +39,7 @@ import {
3939 failEnvironmentAuthInvalid,
4040 failEnvironmentInternal,
4141} from "./auth/http.ts";
42import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
42import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts";
4343import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts";
4444
4545const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
......@@ -47,7 +47,7 @@ const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
4747
4848export const browserApiCorsLayer = Layer.unwrap(
4949 Effect.gen(function* () {
50 const config = yield* ServerConfig;
50 const config = yield* ServerConfig.ServerConfig;
5151 const devOrigin = config.devUrl?.origin;
5252 return HttpRouter.cors({
5353 ...(devOrigin ? { allowedOrigins: [devOrigin], credentials: true } : {}),
......@@ -95,7 +95,7 @@ export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group(
9595 EnvironmentHttpApi,
9696 "metadata",
9797 Effect.fnUntraced(function* (handlers) {
98 const serverEnvironment = yield* ServerEnvironment;
98 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
9999 return handlers.handle(
100100 "descriptor",
101101 Effect.fn("environment.metadata.descriptor")(function* (args) {
......@@ -117,9 +117,9 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add(
117117 Effect.gen(function* () {
118118 yield* authenticateRawRouteWithScope(AuthOrchestrationOperateScope);
119119 const request = yield* HttpServerRequest.HttpServerRequest;
120 const config = yield* ServerConfig;
120 const config = yield* ServerConfig.ServerConfig;
121121 const otlpTracesUrl = config.otlpTracesUrl;
122 const browserTraceCollector = yield* BrowserTraceCollector;
122 const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector;
123123 const httpClient = yield* HttpClient.HttpClient;
124124 const bodyJson = cast<unknown, OtlpTracer.TraceData>(yield* request.json);
125125
......@@ -223,14 +223,15 @@ export const staticAndDevRouteLayer = HttpRouter.add(
223223 return HttpServerResponse.text("Bad Request", { status: 400 });
224224 }
225225
226 const config = yield* ServerConfig;
226 const config = yield* ServerConfig.ServerConfig;
227227 if (config.devUrl && isLoopbackHostname(url.value.hostname)) {
228228 return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), {
229229 status: 302,
230230 });
231231 }
232232
233 const staticDir = config.staticDir ?? (config.devUrl ? yield* resolveStaticDir() : undefined);
233 const staticDir =
234 config.staticDir ?? (config.devUrl ? yield* ServerConfig.resolveStaticDir() : undefined);
234235 if (!staticDir) {
235236 return HttpServerResponse.text("No static directory configured and no dev URL set.", {
236237 status: 503,
apps/server/src/observability/BrowserTraceCollector.ts created+23
......@@ -0,0 +1,23 @@
1import type { TraceRecord, TraceSink } from "@t3tools/shared/observability";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6export class BrowserTraceCollector extends Context.Service<
7 BrowserTraceCollector,
8 {
9 readonly record: (records: ReadonlyArray<TraceRecord>) => Effect.Effect<void>;
10 }
11>()("t3/observability/BrowserTraceCollector") {}
12
13export const make = (sink: TraceSink): BrowserTraceCollector["Service"] =>
14 BrowserTraceCollector.of({
15 record: (records) =>
16 Effect.sync(() => {
17 for (const record of records) {
18 sink.push(record);
19 }
20 }),
21 });
22
23export const layer = (sink: TraceSink) => Layer.succeed(BrowserTraceCollector, make(sink));
apps/server/src/observability/Layers/Observability.ts+7-12
......@@ -4,17 +4,19 @@ import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
55import * as References from "effect/References";
66import * as Tracer from "effect/Tracer";
7import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
7import * as OtlpMetrics from "effect/unstable/observability/OtlpMetrics";
8import * as OtlpSerialization from "effect/unstable/observability/OtlpSerialization";
9import * as OtlpTracer from "effect/unstable/observability/OtlpTracer";
810
9import { ServerConfig } from "../../config.ts";
11import * as ServerConfig from "../../config.ts";
1012import { ServerLoggerLive } from "../../serverLogger.ts";
11import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts";
13import * as BrowserTraceCollector from "../BrowserTraceCollector.ts";
1214
1315const otlpSerializationLayer = OtlpSerialization.layerJson;
1416
1517export const ObservabilityLive = Layer.unwrap(
1618 Effect.gen(function* () {
17 const config = yield* ServerConfig;
19 const config = yield* ServerConfig.ServerConfig;
1820
1921 const traceReferencesLayer = Layer.mergeAll(
2022 Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel),
......@@ -56,14 +58,7 @@ export const ObservabilityLive = Layer.unwrap(
5658
5759 return Layer.mergeAll(
5860 Layer.succeed(Tracer.Tracer, tracer),
59 Layer.succeed(BrowserTraceCollector, {
60 record: (records) =>
61 Effect.sync(() => {
62 for (const record of records) {
63 sink.push(record);
64 }
65 }),
66 }),
61 BrowserTraceCollector.layer(sink),
6762 );
6863 }),
6964 ).pipe(Layer.provideMerge(otlpSerializationLayer));
apps/server/src/observability/Services/BrowserTraceCollector.ts deleted-12
......@@ -1,12 +0,0 @@
1import type { TraceRecord } from "@t3tools/shared/observability";
2import * as Context from "effect/Context";
3import type * as Effect from "effect/Effect";
4
5export interface BrowserTraceCollectorShape {
6 readonly record: (records: ReadonlyArray<TraceRecord>) => Effect.Effect<void>;
7}
8
9export class BrowserTraceCollector extends Context.Service<
10 BrowserTraceCollector,
11 BrowserTraceCollectorShape
12>()("t3/observability/Services/BrowserTraceCollector") {}
apps/server/src/provider/Layers/ProviderService.test.ts+164-75
......@@ -43,14 +43,11 @@ import {
4343 type ProviderAdapterError,
4444} from "../Errors.ts";
4545import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
46import {
47 ProviderAdapterRegistry,
48 type ProviderAdapterRegistryShape,
49} from "../Services/ProviderAdapterRegistry.ts";
50import { ProviderService } from "../Services/ProviderService.ts";
51import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
46import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts";
47import * as ProviderService from "../Services/ProviderService.ts";
48import * as ProviderSessionDirectory from "../Services/ProviderSessionDirectory.ts";
5249import { makeProviderServiceLive } from "./ProviderService.ts";
53import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";
50import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
5451import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts";
5552import * as NodeServices from "@effect/platform-node/NodeServices";
5653import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntime.ts";
......@@ -58,11 +55,11 @@ import {
5855 makeSqlitePersistenceLive,
5956 SqlitePersistenceMemory,
6057} from "../../persistence/Layers/Sqlite.ts";
61import { ServerSettingsService } from "../../serverSettings.ts";
62import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";
58import * as ServerSettings from "../../serverSettings.ts";
59import * as AnalyticsService from "../../telemetry/AnalyticsService.ts";
6360import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts";
6461
65const defaultServerSettingsLayer = ServerSettingsService.layerTest();
62const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest();
6663
6764const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value);
6865const asEventId = (value: string): EventId => EventId.make(value);
......@@ -280,7 +277,10 @@ function makeProviderServiceLayer() {
280277 [ProviderDriverKind.make("cursor")]: cursor.adapter,
281278 });
282279
283 const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry);
280 const providerAdapterLayer = Layer.succeed(
281 ProviderAdapterRegistry.ProviderAdapterRegistry,
282 registry,
283 );
284284 const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(
285285 Layer.provide(SqlitePersistenceMemory),
286286 );
......@@ -293,7 +293,12 @@ function makeProviderServiceLayer() {
293293 Layer.provide(directoryLayer),
294294 Layer.provide(defaultServerSettingsLayer),
295295 Layer.provideMerge(AnalyticsService.layerTest),
296 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
296 Layer.provide(
297 Layer.succeed(
298 ProviderEventLoggers.ProviderEventLoggers,
299 ProviderEventLoggers.NoOpProviderEventLoggers,
300 ),
301 ),
297302 ),
298303 directoryLayer,
299304
......@@ -325,7 +330,10 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () =>
325330 const registry = makeAdapterRegistryMock({
326331 [CODEX_DRIVER]: codex.adapter,
327332 });
328 const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry);
333 const providerAdapterLayer = Layer.succeed(
334 ProviderAdapterRegistry.ProviderAdapterRegistry,
335 registry,
336 );
329337 const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(
330338 Layer.provide(SqlitePersistenceMemory),
331339 );
......@@ -336,7 +344,12 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () =>
336344 Layer.provide(directoryLayer),
337345 Layer.provide(defaultServerSettingsLayer),
338346 Layer.provideMerge(AnalyticsService.layerTest),
339 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
347 Layer.provide(
348 Layer.succeed(
349 ProviderEventLoggers.ProviderEventLoggers,
350 ProviderEventLoggers.NoOpProviderEventLoggers,
351 ),
352 ),
340353 ),
341354 directoryLayer,
342355 runtimeRepositoryLayer,
......@@ -345,7 +358,7 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () =>
345358 const scope = yield* Scope.make();
346359 const runtimeServices = yield* Layer.build(providerLayer).pipe(Scope.provide(scope));
347360
348 yield* ProviderService.pipe(Effect.provide(runtimeServices));
361 yield* ProviderService.ProviderService.pipe(Effect.provide(runtimeServices));
349362 const closeExit = yield* Scope.close(scope, Exit.void).pipe(Effect.exit);
350363
351364 assert.equal(Exit.isSuccess(closeExit), true);
......@@ -361,7 +374,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", ()
361374 [CODEX_DRIVER]: codex.adapter,
362375 [CLAUDE_AGENT_DRIVER]: claude.adapter,
363376 });
364 const registry: ProviderAdapterRegistryShape = {
377 const registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] = {
365378 ...registryBase,
366379 getInstanceInfo: (instanceId) =>
367380 instanceId === claudeAgentInstanceId
......@@ -377,7 +390,10 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", ()
377390 })
378391 : registryBase.getInstanceInfo(instanceId),
379392 };
380 const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry);
393 const providerAdapterLayer = Layer.succeed(
394 ProviderAdapterRegistry.ProviderAdapterRegistry,
395 registry,
396 );
381397 const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(
382398 Layer.provide(SqlitePersistenceMemory),
383399 );
......@@ -387,12 +403,17 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", ()
387403 Layer.provide(directoryLayer),
388404 Layer.provide(defaultServerSettingsLayer),
389405 Layer.provide(AnalyticsService.layerTest),
390 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
406 Layer.provide(
407 Layer.succeed(
408 ProviderEventLoggers.ProviderEventLoggers,
409 ProviderEventLoggers.NoOpProviderEventLoggers,
410 ),
411 ),
391412 );
392413
393414 const failure = yield* Effect.flip(
394415 Effect.gen(function* () {
395 const provider = yield* ProviderService;
416 const provider = yield* ProviderService.ProviderService;
396417 return yield* provider.startSession(asThreadId("thread-disabled"), {
397418 provider: ProviderDriverKind.make("claudeAgent"),
398419 providerInstanceId: claudeAgentInstanceId,
......@@ -419,7 +440,7 @@ it.effect(
419440 new ProviderUnsupportedError({
420441 provider: driverKind,
421442 });
422 const registry: ProviderAdapterRegistryShape = {
443 const registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] = {
423444 getByInstance: (requestedInstanceId) =>
424445 requestedInstanceId === instanceId
425446 ? Effect.succeed(codex.adapter)
......@@ -444,8 +465,11 @@ it.effect(
444465 PubSub.subscribe(pubsub),
445466 ),
446467 };
447 const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry);
448 const serverSettingsLayer = ServerSettingsService.layerTest({
468 const providerAdapterLayer = Layer.succeed(
469 ProviderAdapterRegistry.ProviderAdapterRegistry,
470 registry,
471 );
472 const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest({
449473 providers: {
450474 codex: {
451475 enabled: false,
......@@ -463,11 +487,16 @@ it.effect(
463487 Layer.provide(directoryLayer),
464488 Layer.provide(serverSettingsLayer),
465489 Layer.provide(AnalyticsService.layerTest),
466 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
490 Layer.provide(
491 Layer.succeed(
492 ProviderEventLoggers.ProviderEventLoggers,
493 ProviderEventLoggers.NoOpProviderEventLoggers,
494 ),
495 ),
467496 );
468497
469498 const session = yield* Effect.gen(function* () {
470 const provider = yield* ProviderService;
499 const provider = yield* ProviderService.ProviderService;
471500 return yield* provider.startSession(asThreadId("thread-enabled-custom"), {
472501 provider: driverKind,
473502 providerInstanceId: instanceId,
......@@ -490,7 +519,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance
490519 new ProviderUnsupportedError({
491520 provider: ProviderDriverKind.make("codex"),
492521 });
493 const registry: ProviderAdapterRegistryShape = {
522 const registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] = {
494523 getByInstance: (requestedInstanceId) =>
495524 requestedInstanceId === instanceId
496525 ? Effect.succeed(codex.adapter)
......@@ -515,7 +544,10 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance
515544 PubSub.subscribe(pubsub),
516545 ),
517546 };
518 const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry);
547 const providerAdapterLayer = Layer.succeed(
548 ProviderAdapterRegistry.ProviderAdapterRegistry,
549 registry,
550 );
519551 const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(
520552 Layer.provide(SqlitePersistenceMemory),
521553 );
......@@ -525,12 +557,17 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance
525557 Layer.provide(directoryLayer),
526558 Layer.provide(defaultServerSettingsLayer),
527559 Layer.provide(AnalyticsService.layerTest),
528 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
560 Layer.provide(
561 Layer.succeed(
562 ProviderEventLoggers.ProviderEventLoggers,
563 ProviderEventLoggers.NoOpProviderEventLoggers,
564 ),
565 ),
529566 );
530567
531568 const failure = yield* Effect.flip(
532569 Effect.gen(function* () {
533 const provider = yield* ProviderService;
570 const provider = yield* ProviderService.ProviderService;
534571 return yield* provider.startSession(asThreadId("thread-disabled-instance"), {
535572 provider: ProviderDriverKind.make("codex"),
536573 providerInstanceId: instanceId,
......@@ -571,15 +608,20 @@ it.effect("ProviderServiceLive writes canonical events to the emitting thread se
571608 close: () => Effect.void,
572609 },
573610 }).pipe(
574 Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)),
611 Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)),
575612 Layer.provide(directoryLayer),
576613 Layer.provide(defaultServerSettingsLayer),
577614 Layer.provide(AnalyticsService.layerTest),
578 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
615 Layer.provide(
616 Layer.succeed(
617 ProviderEventLoggers.ProviderEventLoggers,
618 ProviderEventLoggers.NoOpProviderEventLoggers,
619 ),
620 ),
579621 );
580622
581623 yield* Effect.gen(function* () {
582 yield* ProviderService;
624 yield* ProviderService.ProviderService;
583625 yield* advanceTestClock(10);
584626 codex.emit({
585627 eventId: asEventId("evt-canonical-thread-segment"),
......@@ -617,7 +659,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (
617659 const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));
618660
619661 yield* Effect.gen(function* () {
620 const directory = yield* ProviderSessionDirectory;
662 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
621663 yield* directory.upsert({
622664 provider: ProviderDriverKind.make("codex"),
623665 providerInstanceId: codexInstanceId,
......@@ -626,17 +668,22 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", (
626668 }).pipe(Effect.provide(directoryLayer));
627669
628670 const providerLayer = makeProviderServiceLive().pipe(
629 Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)),
671 Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)),
630672 Layer.provide(directoryLayer),
631673 Layer.provide(defaultServerSettingsLayer),
632674 Layer.provide(AnalyticsService.layerTest),
633 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
675 Layer.provide(
676 Layer.succeed(
677 ProviderEventLoggers.ProviderEventLoggers,
678 ProviderEventLoggers.NoOpProviderEventLoggers,
679 ),
680 ),
634681 );
635682
636 yield* ProviderService.pipe(Effect.provide(providerLayer));
683 yield* ProviderService.ProviderService.pipe(Effect.provide(providerLayer));
637684
638685 const persistedProvider = yield* Effect.gen(function* () {
639 const directory = yield* ProviderSessionDirectory;
686 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
640687 return yield* directory.getProvider(asThreadId("thread-stale"));
641688 }).pipe(Effect.provide(directoryLayer));
642689 assert.equal(persistedProvider, "codex");
......@@ -683,11 +730,18 @@ it.effect(
683730 Layer.provide(runtimeRepositoryLayer),
684731 );
685732 const firstProviderLayer = makeProviderServiceLive().pipe(
686 Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)),
733 Layer.provide(
734 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry),
735 ),
687736 Layer.provide(firstDirectoryLayer),
688737 Layer.provide(defaultServerSettingsLayer),
689738 Layer.provide(AnalyticsService.layerTest),
690 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
739 Layer.provide(
740 Layer.succeed(
741 ProviderEventLoggers.ProviderEventLoggers,
742 ProviderEventLoggers.NoOpProviderEventLoggers,
743 ),
744 ),
691745 );
692746 const updatedResumeCursor = {
693747 threadId: asThreadId("thread-1"),
......@@ -697,7 +751,7 @@ it.effect(
697751 };
698752
699753 const startedSession = yield* Effect.gen(function* () {
700 const provider = yield* ProviderService;
754 const provider = yield* ProviderService.ProviderService;
701755 const threadId = asThreadId("thread-1");
702756 const session = yield* provider.startSession(threadId, {
703757 provider: ProviderDriverKind.make("codex"),
......@@ -735,18 +789,25 @@ it.effect(
735789 Layer.provide(runtimeRepositoryLayer),
736790 );
737791 const secondProviderLayer = makeProviderServiceLive().pipe(
738 Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)),
792 Layer.provide(
793 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry),
794 ),
739795 Layer.provide(secondDirectoryLayer),
740796 Layer.provide(defaultServerSettingsLayer),
741797 Layer.provide(AnalyticsService.layerTest),
742 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
798 Layer.provide(
799 Layer.succeed(
800 ProviderEventLoggers.ProviderEventLoggers,
801 ProviderEventLoggers.NoOpProviderEventLoggers,
802 ),
803 ),
743804 );
744805
745806 secondCodex.startSession.mockClear();
746807 secondCodex.rollbackThread.mockClear();
747808
748809 yield* Effect.gen(function* () {
749 const provider = yield* ProviderService;
810 const provider = yield* ProviderService.ProviderService;
750811 yield* provider.rollbackConversation({
751812 threadId: startedSession.threadId,
752813 numTurns: 1,
......@@ -780,7 +841,7 @@ it.effect(
780841routing.layer("ProviderServiceLive routing", (it) => {
781842 it.effect("routes provider operations and rollback conversation", () =>
782843 Effect.gen(function* () {
783 const provider = yield* ProviderService;
844 const provider = yield* ProviderService.ProviderService;
784845
785846 const session = yield* provider.startSession(asThreadId("thread-1"), {
786847 provider: ProviderDriverKind.make("codex"),
......@@ -866,7 +927,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
866927
867928 it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () =>
868929 Effect.gen(function* () {
869 const provider = yield* ProviderService;
930 const provider = yield* ProviderService.ProviderService;
870931
871932 const initial = yield* provider.startSession(asThreadId("thread-1"), {
872933 provider: ProviderDriverKind.make("codex"),
......@@ -907,7 +968,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
907968
908969 it.effect("preserves the persisted binding when stopping a session", () =>
909970 Effect.gen(function* () {
910 const provider = yield* ProviderService;
971 const provider = yield* ProviderService.ProviderService;
911972 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
912973
913974 const initial = yield* provider.startSession(asThreadId("thread-reap-preserve"), {
......@@ -959,7 +1020,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
9591020
9601021 it.effect("routes explicit claudeAgent provider session starts to the claude adapter", () =>
9611022 Effect.gen(function* () {
962 const provider = yield* ProviderService;
1023 const provider = yield* ProviderService.ProviderService;
9631024
9641025 const session = yield* provider.startSession(asThreadId("thread-claude"), {
9651026 provider: ProviderDriverKind.make("claudeAgent"),
......@@ -988,8 +1049,8 @@ routing.layer("ProviderServiceLive routing", (it) => {
9881049
9891050 it.effect("dies when an active session conflicts with its persisted binding", () =>
9901051 Effect.gen(function* () {
991 const provider = yield* ProviderService;
992 const directory = yield* ProviderSessionDirectory;
1052 const provider = yield* ProviderService.ProviderService;
1053 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
9931054 const threadId = asThreadId("thread-binding-mismatch");
9941055
9951056 yield* provider.startSession(threadId, {
......@@ -1019,7 +1080,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
10191080
10201081 it.effect("stops stale sessions in other providers after a successful replacement start", () =>
10211082 Effect.gen(function* () {
1022 const provider = yield* ProviderService;
1083 const provider = yield* ProviderService.ProviderService;
10231084 const threadId = asThreadId("thread-provider-replacement");
10241085
10251086 const codexSession = yield* provider.startSession(threadId, {
......@@ -1058,7 +1119,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
10581119
10591120 it.effect("recovers stale sessions for sendTurn using persisted cwd", () =>
10601121 Effect.gen(function* () {
1061 const provider = yield* ProviderService;
1122 const provider = yield* ProviderService.ProviderService;
10621123
10631124 const initial = yield* provider.startSession(asThreadId("thread-1"), {
10641125 provider: ProviderDriverKind.make("codex"),
......@@ -1099,7 +1160,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
10991160
11001161 it.effect("recovers stale claudeAgent sessions for sendTurn using persisted cwd", () =>
11011162 Effect.gen(function* () {
1102 const provider = yield* ProviderService;
1163 const provider = yield* ProviderService.ProviderService;
11031164
11041165 const initial = yield* provider.startSession(asThreadId("thread-claude-send-turn"), {
11051166 provider: ProviderDriverKind.make("claudeAgent"),
......@@ -1152,7 +1213,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
11521213
11531214 it.effect("lists no sessions after adapter runtime clears", () =>
11541215 Effect.gen(function* () {
1155 const provider = yield* ProviderService;
1216 const provider = yield* ProviderService.ProviderService;
11561217
11571218 yield* provider.startSession(asThreadId("thread-1"), {
11581219 provider: ProviderDriverKind.make("codex"),
......@@ -1177,7 +1238,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
11771238
11781239 it.effect("persists runtime status transitions in provider_session_runtime", () =>
11791240 Effect.gen(function* () {
1180 const provider = yield* ProviderService;
1241 const provider = yield* ProviderService.ProviderService;
11811242 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
11821243
11831244 const threadId = asThreadId("thread-runtime-status");
......@@ -1237,15 +1298,22 @@ routing.layer("ProviderServiceLive routing", (it) => {
12371298 Layer.provide(runtimeRepositoryLayer),
12381299 );
12391300 const firstProviderLayer = makeProviderServiceLive().pipe(
1240 Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)),
1301 Layer.provide(
1302 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry),
1303 ),
12411304 Layer.provide(firstDirectoryLayer),
12421305 Layer.provide(defaultServerSettingsLayer),
12431306 Layer.provide(AnalyticsService.layerTest),
1244 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
1307 Layer.provide(
1308 Layer.succeed(
1309 ProviderEventLoggers.ProviderEventLoggers,
1310 ProviderEventLoggers.NoOpProviderEventLoggers,
1311 ),
1312 ),
12451313 );
12461314
12471315 const initial = yield* Effect.gen(function* () {
1248 const provider = yield* ProviderService;
1316 const provider = yield* ProviderService.ProviderService;
12491317 return yield* provider.startSession(asThreadId("thread-claude-start"), {
12501318 provider: ProviderDriverKind.make("claudeAgent"),
12511319 providerInstanceId: claudeAgentInstanceId,
......@@ -1256,7 +1324,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
12561324 }).pipe(Effect.provide(firstProviderLayer));
12571325
12581326 yield* Effect.gen(function* () {
1259 const provider = yield* ProviderService;
1327 const provider = yield* ProviderService.ProviderService;
12601328 yield* provider.listSessions();
12611329 }).pipe(Effect.provide(firstProviderLayer));
12621330
......@@ -1268,17 +1336,24 @@ routing.layer("ProviderServiceLive routing", (it) => {
12681336 Layer.provide(runtimeRepositoryLayer),
12691337 );
12701338 const secondProviderLayer = makeProviderServiceLive().pipe(
1271 Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)),
1339 Layer.provide(
1340 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry),
1341 ),
12721342 Layer.provide(secondDirectoryLayer),
12731343 Layer.provide(defaultServerSettingsLayer),
12741344 Layer.provide(AnalyticsService.layerTest),
1275 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
1345 Layer.provide(
1346 Layer.succeed(
1347 ProviderEventLoggers.ProviderEventLoggers,
1348 ProviderEventLoggers.NoOpProviderEventLoggers,
1349 ),
1350 ),
12761351 );
12771352
12781353 secondClaude.startSession.mockClear();
12791354
12801355 yield* Effect.gen(function* () {
1281 const provider = yield* ProviderService;
1356 const provider = yield* ProviderService.ProviderService;
12821357 yield* provider.startSession(initial.threadId, {
12831358 provider: ProviderDriverKind.make("claudeAgent"),
12841359 providerInstanceId: claudeAgentInstanceId,
......@@ -1327,15 +1402,22 @@ routing.layer("ProviderServiceLive routing", (it) => {
13271402 Layer.provide(runtimeRepositoryLayer),
13281403 );
13291404 const firstProviderLayer = makeProviderServiceLive().pipe(
1330 Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)),
1405 Layer.provide(
1406 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry),
1407 ),
13311408 Layer.provide(firstDirectoryLayer),
13321409 Layer.provide(defaultServerSettingsLayer),
13331410 Layer.provide(AnalyticsService.layerTest),
1334 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
1411 Layer.provide(
1412 Layer.succeed(
1413 ProviderEventLoggers.ProviderEventLoggers,
1414 ProviderEventLoggers.NoOpProviderEventLoggers,
1415 ),
1416 ),
13351417 );
13361418
13371419 const initial = yield* Effect.gen(function* () {
1338 const provider = yield* ProviderService;
1420 const provider = yield* ProviderService.ProviderService;
13391421 return yield* provider.startSession(asThreadId("thread-claude-cwd"), {
13401422 provider: ProviderDriverKind.make("claudeAgent"),
13411423 providerInstanceId: claudeAgentInstanceId,
......@@ -1353,17 +1435,24 @@ routing.layer("ProviderServiceLive routing", (it) => {
13531435 Layer.provide(runtimeRepositoryLayer),
13541436 );
13551437 const secondProviderLayer = makeProviderServiceLive().pipe(
1356 Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)),
1438 Layer.provide(
1439 Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry),
1440 ),
13571441 Layer.provide(secondDirectoryLayer),
13581442 Layer.provide(defaultServerSettingsLayer),
13591443 Layer.provide(AnalyticsService.layerTest),
1360 Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
1444 Layer.provide(
1445 Layer.succeed(
1446 ProviderEventLoggers.ProviderEventLoggers,
1447 ProviderEventLoggers.NoOpProviderEventLoggers,
1448 ),
1449 ),
13611450 );
13621451
13631452 secondClaude.startSession.mockClear();
13641453
13651454 yield* Effect.gen(function* () {
1366 const provider = yield* ProviderService;
1455 const provider = yield* ProviderService.ProviderService;
13671456 yield* provider.startSession(initial.threadId, {
13681457 provider: ProviderDriverKind.make("claudeAgent"),
13691458 providerInstanceId: claudeAgentInstanceId,
......@@ -1397,7 +1486,7 @@ const fanout = makeProviderServiceLayer();
13971486fanout.layer("ProviderServiceLive fanout", (it) => {
13981487 it.effect("fans out adapter turn completion events", () =>
13991488 Effect.gen(function* () {
1400 const provider = yield* ProviderService;
1489 const provider = yield* ProviderService.ProviderService;
14011490 const session = yield* provider.startSession(asThreadId("thread-1"), {
14021491 provider: ProviderDriverKind.make("codex"),
14031492 providerInstanceId: codexInstanceId,
......@@ -1443,7 +1532,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
14431532
14441533 it.effect("fans out canonical runtime events in emission order", () =>
14451534 Effect.gen(function* () {
1446 const provider = yield* ProviderService;
1535 const provider = yield* ProviderService.ProviderService;
14471536 const session = yield* provider.startSession(asThreadId("thread-seq"), {
14481537 provider: ProviderDriverKind.make("codex"),
14491538 providerInstanceId: codexInstanceId,
......@@ -1499,7 +1588,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
14991588
15001589 it.effect("keeps subscriber delivery ordered and isolates failing subscribers", () =>
15011590 Effect.gen(function* () {
1502 const provider = yield* ProviderService;
1591 const provider = yield* ProviderService.ProviderService;
15031592 const session = yield* provider.startSession(asThreadId("thread-1"), {
15041593 provider: ProviderDriverKind.make("codex"),
15051594 providerInstanceId: codexInstanceId,
......@@ -1571,7 +1660,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
15711660
15721661 it.effect("records provider metrics with the routed provider label", () =>
15731662 Effect.gen(function* () {
1574 const provider = yield* ProviderService;
1663 const provider = yield* ProviderService.ProviderService;
15751664
15761665 const session = yield* provider.startSession(asThreadId("thread-metrics"), {
15771666 provider: ProviderDriverKind.make("claudeAgent"),
......@@ -1649,7 +1738,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
16491738 "records sendTurn metrics with the resolved provider when modelSelection is omitted",
16501739 () =>
16511740 Effect.gen(function* () {
1652 const provider = yield* ProviderService;
1741 const provider = yield* ProviderService.ProviderService;
16531742
16541743 const session = yield* provider.startSession(asThreadId("thread-send-metrics"), {
16551744 provider: ProviderDriverKind.make("claudeAgent"),
......@@ -1690,7 +1779,7 @@ const validation = makeProviderServiceLayer();
16901779validation.layer("ProviderServiceLive validation", (it) => {
16911780 it.effect("rejects session starts without an explicit provider instance id", () =>
16921781 Effect.gen(function* () {
1693 const provider = yield* ProviderService;
1782 const provider = yield* ProviderService.ProviderService;
16941783
16951784 validation.codex.startSession.mockClear();
16961785 const failure = yield* Effect.flip(
......@@ -1709,7 +1798,7 @@ validation.layer("ProviderServiceLive validation", (it) => {
17091798
17101799 it.effect("rejects mismatched provider kind and provider instance id", () =>
17111800 Effect.gen(function* () {
1712 const provider = yield* ProviderService;
1801 const provider = yield* ProviderService.ProviderService;
17131802
17141803 validation.codex.startSession.mockClear();
17151804 validation.claude.startSession.mockClear();
......@@ -1734,7 +1823,7 @@ validation.layer("ProviderServiceLive validation", (it) => {
17341823
17351824 it.effect("returns ProviderValidationError for invalid input payloads", () =>
17361825 Effect.gen(function* () {
1737 const provider = yield* ProviderService;
1826 const provider = yield* ProviderService.ProviderService;
17381827
17391828 const failure = yield* Effect.result(
17401829 provider.startSession(asThreadId("thread-validation"), {
......@@ -1759,7 +1848,7 @@ validation.layer("ProviderServiceLive validation", (it) => {
17591848
17601849 it.effect("accepts startSession when adapter has not emitted provider thread id yet", () =>
17611850 Effect.gen(function* () {
1762 const provider = yield* ProviderService;
1851 const provider = yield* ProviderService.ProviderService;
17631852 const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
17641853
17651854 validation.codex.startSession.mockImplementationOnce((input: ProviderSessionStartInput) =>
apps/server/src/provider/Layers/ProviderService.ts+44-32
......@@ -47,15 +47,12 @@ import {
4747} from "../../observability/Metrics.ts";
4848import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts";
4949import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
50import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
51import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts";
52import {
53 ProviderSessionDirectory,
54 type ProviderRuntimeBinding,
55} from "../Services/ProviderSessionDirectory.ts";
50import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts";
51import * as ProviderService from "../Services/ProviderService.ts";
52import * as ProviderSessionDirectory from "../Services/ProviderSessionDirectory.ts";
5653import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts";
57import { ProviderEventLoggers } from "./ProviderEventLoggers.ts";
58import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";
54import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
55import * as AnalyticsService from "../../telemetry/AnalyticsService.ts";
5956import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
6057import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts";
6158const isModelSelection = Schema.is(ModelSelection);
......@@ -69,6 +66,9 @@ export interface ProviderServiceLiveOptions {
6966 readonly canonicalEventLogger?: EventNdjsonLogger;
7067}
7168
69type ProviderServiceMethod<Name extends keyof ProviderService.ProviderService["Service"]> =
70 ProviderService.ProviderService["Service"][Name];
71
7272const ProviderRollbackConversationInput = Schema.Struct({
7373 threadId: ThreadId,
7474 numTurns: NonNegativeInt,
......@@ -141,7 +141,7 @@ function toRuntimePayloadFromSession(
141141}
142142
143143function readPersistedModelSelection(
144 runtimePayload: ProviderRuntimeBinding["runtimePayload"],
144 runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"],
145145): ModelSelection | undefined {
146146 if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) {
147147 return undefined;
......@@ -151,7 +151,7 @@ function readPersistedModelSelection(
151151}
152152
153153function readPersistedCwd(
154 runtimePayload: ProviderRuntimeBinding["runtimePayload"],
154 runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"],
155155): string | undefined {
156156 if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) {
157157 return undefined;
......@@ -202,16 +202,16 @@ const correlateRuntimeEventWithInstance = (
202202const makeProviderService = Effect.fn("makeProviderService")(function* (
203203 options?: ProviderServiceLiveOptions,
204204) {
205 const analytics = yield* Effect.service(AnalyticsService);
206 const eventLoggers = yield* ProviderEventLoggers;
205 const analytics = yield* Effect.service(AnalyticsService.AnalyticsService);
206 const eventLoggers = yield* ProviderEventLoggers.ProviderEventLoggers;
207207 // Options-provided logger wins (test overrides); otherwise we take whatever
208208 // the `ProviderEventLoggers` tag exposes — `undefined` means "no canonical
209209 // log writer is attached", which downstream code already handles as a
210210 // no-op.
211211 const canonicalEventLogger = options?.canonicalEventLogger ?? eventLoggers.canonical;
212212
213 const registry = yield* ProviderAdapterRegistry;
214 const directory = yield* ProviderSessionDirectory;
213 const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry;
214 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
215215 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
216216 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
217217 const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) =>
......@@ -353,7 +353,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
353353 ).pipe(Effect.forkScoped);
354354
355355 const recoverSessionForThread = Effect.fn("recoverSessionForThread")(function* (input: {
356 readonly binding: ProviderRuntimeBinding;
356 readonly binding: ProviderSessionDirectory.ProviderRuntimeBinding;
357357 readonly operation: string;
358358 }) {
359359 const bindingInstanceId = yield* requireBindingInstanceId(input.operation, input.binding);
......@@ -519,7 +519,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
519519 );
520520 });
521521
522 const startSession: ProviderServiceShape["startSession"] = Effect.fn("startSession")(
522 const startSession: ProviderServiceMethod<"startSession"> = Effect.fn("startSession")(
523523 function* (threadId, rawInput) {
524524 const parsed = yield* decodeInputOrValidationError({
525525 operation: "ProviderService.startSession",
......@@ -642,7 +642,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
642642 },
643643 );
644644
645 const sendTurn: ProviderServiceShape["sendTurn"] = Effect.fn("sendTurn")(function* (rawInput) {
645 const sendTurn: ProviderServiceMethod<"sendTurn"> = Effect.fn("sendTurn")(function* (rawInput) {
646646 const parsed = yield* decodeInputOrValidationError({
647647 operation: "ProviderService.sendTurn",
648648 schema: ProviderSendTurnInput,
......@@ -717,7 +717,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
717717 );
718718 });
719719
720 const interruptTurn: ProviderServiceShape["interruptTurn"] = Effect.fn("interruptTurn")(
720 const interruptTurn: ProviderServiceMethod<"interruptTurn"> = Effect.fn("interruptTurn")(
721721 function* (rawInput) {
722722 const input = yield* decodeInputOrValidationError({
723723 operation: "ProviderService.interruptTurn",
......@@ -754,7 +754,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
754754 },
755755 );
756756
757 const respondToRequest: ProviderServiceShape["respondToRequest"] = Effect.fn("respondToRequest")(
757 const respondToRequest: ProviderServiceMethod<"respondToRequest"> = Effect.fn("respondToRequest")(
758758 function* (rawInput) {
759759 const input = yield* decodeInputOrValidationError({
760760 operation: "ProviderService.respondToRequest",
......@@ -792,7 +792,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
792792 },
793793 );
794794
795 const respondToUserInput: ProviderServiceShape["respondToUserInput"] = Effect.fn(
795 const respondToUserInput: ProviderServiceMethod<"respondToUserInput"> = Effect.fn(
796796 "respondToUserInput",
797797 )(function* (rawInput) {
798798 const input = yield* decodeInputOrValidationError({
......@@ -826,7 +826,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
826826 );
827827 });
828828
829 const stopSession: ProviderServiceShape["stopSession"] = Effect.fn("stopSession")(
829 const stopSession: ProviderServiceMethod<"stopSession"> = Effect.fn("stopSession")(
830830 function* (rawInput) {
831831 const input = yield* decodeInputOrValidationError({
832832 operation: "ProviderService.stopSession",
......@@ -874,7 +874,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
874874 },
875875 );
876876
877 const listSessions: ProviderServiceShape["listSessions"] = Effect.fn("listSessions")(
877 const listSessions: ProviderServiceMethod<"listSessions"> = Effect.fn("listSessions")(
878878 function* () {
879879 const currentAdapters = yield* getAdapterEntries;
880880 const sessionsByProvider = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) =>
......@@ -895,13 +895,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
895895 (threadId) =>
896896 directory
897897 .getBinding(threadId)
898 .pipe(Effect.orElseSucceed(() => Option.none<ProviderRuntimeBinding>())),
898 .pipe(
899 Effect.orElseSucceed(() =>
900 Option.none<ProviderSessionDirectory.ProviderRuntimeBinding>(),
901 ),
902 ),
899903 { concurrency: "unbounded" },
900904 ),
901905 ),
902 Effect.orElseSucceed(() => [] as Array<Option.Option<ProviderRuntimeBinding>>),
906 Effect.orElseSucceed(
907 () => [] as Array<Option.Option<ProviderSessionDirectory.ProviderRuntimeBinding>>,
908 ),
903909 );
904 const bindingsByThreadId = new Map<ThreadId, ProviderRuntimeBinding>();
910 const bindingsByThreadId = new Map<
911 ThreadId,
912 ProviderSessionDirectory.ProviderRuntimeBinding
913 >();
905914 for (const bindingOption of persistedBindings) {
906915 const binding = Option.getOrUndefined(bindingOption);
907916 if (binding) {
......@@ -952,13 +961,13 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
952961 },
953962 );
954963
955 const getCapabilities: ProviderServiceShape["getCapabilities"] = (instanceId) =>
964 const getCapabilities: ProviderServiceMethod<"getCapabilities"> = (instanceId) =>
956965 registry.getByInstance(instanceId).pipe(Effect.map((adapter) => adapter.capabilities));
957966
958 const getInstanceInfo: ProviderServiceShape["getInstanceInfo"] = (instanceId) =>
967 const getInstanceInfo: ProviderServiceMethod<"getInstanceInfo"> = (instanceId) =>
959968 registry.getInstanceInfo(instanceId);
960969
961 const rollbackConversation: ProviderServiceShape["rollbackConversation"] = Effect.fn(
970 const rollbackConversation: ProviderServiceMethod<"rollbackConversation"> = Effect.fn(
962971 "rollbackConversation",
963972 )(function* (rawInput) {
964973 const input = yield* decodeInputOrValidationError({
......@@ -1071,14 +1080,17 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
10711080 // Each access creates a fresh PubSub subscription so that multiple
10721081 // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each
10731082 // independently receive all runtime events.
1074 get streamEvents(): ProviderServiceShape["streamEvents"] {
1083 get streamEvents(): ProviderServiceMethod<"streamEvents"> {
10751084 return Stream.fromPubSub(runtimeEventPubSub);
10761085 },
1077 } satisfies ProviderServiceShape;
1086 } satisfies ProviderService.ProviderService["Service"];
10781087});
10791088
1080export const ProviderServiceLive = Layer.effect(ProviderService, makeProviderService());
1089export const ProviderServiceLive = Layer.effect(
1090 ProviderService.ProviderService,
1091 makeProviderService(),
1092);
10811093
10821094export function makeProviderServiceLive(options?: ProviderServiceLiveOptions) {
1083 return Layer.effect(ProviderService, makeProviderService(options));
1095 return Layer.effect(ProviderService.ProviderService, makeProviderService(options));
10841096}
apps/server/src/server.test.ts+1-1
......@@ -88,7 +88,7 @@ import * as ServerSettings from "./serverSettings.ts";
8888import * as TerminalManager from "./terminal/Services/Manager.ts";
8989import * as PreviewManager from "./preview/Manager.ts";
9090import * as PortScanner from "./preview/PortScanner.ts";
91import * as BrowserTraceCollector from "./observability/Services/BrowserTraceCollector.ts";
91import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
9292import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
9393import * as ProjectSetupScriptRunner from "./project/Services/ProjectSetupScriptRunner.ts";
9494import * as RepositoryIdentityResolver from "./project/Services/RepositoryIdentityResolver.ts";
apps/server/src/server.ts+2-2
......@@ -17,7 +17,7 @@ import { websocketRpcRouteLayer } from "./ws.ts";
1717import * as ExternalLauncher from "./process/externalLauncher.ts";
1818import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
1919import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
20import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts";
20import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
2121import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts";
2222import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts";
2323import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";
......@@ -332,7 +332,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(
332332 Layer.provideMerge(ProcessDiagnostics.layer),
333333 Layer.provideMerge(ProcessResourceMonitor.layer),
334334 Layer.provideMerge(TraceDiagnostics.layer),
335 Layer.provideMerge(AnalyticsServiceLayerLive),
335 Layer.provideMerge(AnalyticsService.layer),
336336 Layer.provideMerge(ExternalLauncher.layer),
337337 Layer.provideMerge(ServerLifecycleEvents.layer),
338338 Layer.provide(NetService.layer),
apps/server/src/serverRuntimeStartup.test.ts+1-1
......@@ -13,7 +13,7 @@ import * as Stream from "effect/Stream";
1313import * as ServerConfig from "./config.ts";
1414import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
1515import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
16import * as AnalyticsService from "./telemetry/Services/AnalyticsService.ts";
16import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
1717import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
1818
1919it("uses the canonical Codex default for auto-bootstrapped model selection", () => {
apps/server/src/serverRuntimeStartup.ts+1-1
......@@ -31,7 +31,7 @@ import * as OrchestrationReactor from "./orchestration/Services/OrchestrationRea
3131import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
3232import * as ServerSettings from "./serverSettings.ts";
3333import * as ServerEnvironment from "./environment/Services/ServerEnvironment.ts";
34import * as AnalyticsService from "./telemetry/Services/AnalyticsService.ts";
34import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
3535import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
3636import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts";
3737import {
apps/server/src/telemetry/AnalyticsService.test.ts created+120
......@@ -0,0 +1,120 @@
1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as HttpServer from "effect/unstable/http/HttpServer";
8import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
10
11import * as ServerConfig from "../config.ts";
12import { getTelemetryIdentifier } from "./Identify.ts";
13import * as AnalyticsService from "./AnalyticsService.ts";
14
15interface RecordedBatchRequest {
16 readonly path: string;
17 readonly body: {
18 readonly batch?: ReadonlyArray<{
19 readonly event?: string;
20 readonly properties?: {
21 readonly index?: number;
22 readonly clientType?: string;
23 };
24 }>;
25 } | null;
26}
27
28interface RecordedBatchBody {
29 readonly batch: ReadonlyArray<{
30 readonly event?: string;
31 readonly properties?: {
32 readonly index?: number;
33 readonly clientType?: string;
34 };
35 }>;
36}
37
38it.layer(NodeServices.layer)("AnalyticsService test", (it) => {
39 it.effect("flush drains all buffered events across multiple batches", () =>
40 Effect.gen(function* () {
41 const capturedRequests: Array<RecordedBatchRequest> = [];
42 const serverConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
43 prefix: "t3-telemetry-base-",
44 });
45
46 const telemetryLayer = AnalyticsService.layer.pipe(Layer.provideMerge(serverConfigLayer));
47 const configLayer = ConfigProvider.layer(
48 ConfigProvider.fromUnknown({
49 T3CODE_TELEMETRY_ENABLED: true,
50 T3CODE_POSTHOG_KEY: "phc_test_key",
51 T3CODE_POSTHOG_HOST: "",
52 T3CODE_TELEMETRY_FLUSH_BATCH_SIZE: 20,
53 }),
54 );
55 const batchServerLayer = HttpServer.serve(
56 Effect.gen(function* () {
57 const request = yield* HttpServerRequest.HttpServerRequest;
58 if (request.method !== "POST") {
59 return HttpServerResponse.empty({ status: 404 });
60 }
61
62 const payload = yield* request.json.pipe(
63 Effect.map((body) => body as RecordedBatchRequest["body"]),
64 Effect.orElseSucceed(() => null),
65 );
66
67 capturedRequests.push({ path: request.url, body: payload });
68
69 return HttpServerResponse.jsonUnsafe({});
70 }),
71 );
72 const runtimeLayer = telemetryLayer.pipe(
73 Layer.provide(configLayer),
74 Layer.provideMerge(NodeHttpServer.layerTest),
75 );
76
77 yield* Effect.gen(function* () {
78 yield* Layer.launch(batchServerLayer).pipe(Effect.forkScoped);
79 const telemetryIdentifier = yield* getTelemetryIdentifier;
80 assert.equal(telemetryIdentifier !== null, true);
81 const analytics = yield* AnalyticsService.AnalyticsService;
82
83 for (let index = 0; index < 45; index += 1) {
84 yield* analytics.record("test.flush.drain", { index });
85 }
86
87 yield* analytics.flush;
88 }).pipe(Effect.provide(runtimeLayer));
89
90 const batchRequests = capturedRequests.filter(
91 (request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } =>
92 Array.isArray(request.body?.batch),
93 );
94 assert.equal(batchRequests.length, 3);
95 assert.equal(
96 batchRequests.every((request) => request.path === "/batch/" || request.path === "/batch"),
97 true,
98 );
99 const deliveredIndexes = batchRequests.flatMap((request) =>
100 request.body.batch
101 .filter((event) => event.event === "test.flush.drain")
102 .map((event) => event.properties?.index)
103 .filter((index): index is number => typeof index === "number"),
104 );
105
106 const sorted = deliveredIndexes.toSorted((a, b) => a - b);
107 assert.equal(sorted.length, 45);
108 assert.deepEqual(
109 sorted,
110 Array.from({ length: 45 }, (_, index) => index),
111 );
112 assert.equal(
113 batchRequests.every((request) =>
114 request.body.batch.every((event) => event.properties?.clientType === "cli-web-client"),
115 ),
116 true,
117 );
118 }),
119 );
120});
apps/server/src/telemetry/AnalyticsService.ts created+186
......@@ -0,0 +1,186 @@
1/**
2 * Anonymous PostHog telemetry service.
3 *
4 * Persists an installation-scoped anonymous identifier, buffers events in
5 * memory, and flushes batches over Effect's HTTP client.
6 *
7 * @module AnalyticsService
8 */
9import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
10import * as Config from "effect/Config";
11import * as Context from "effect/Context";
12import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Ref from "effect/Ref";
17import * as HttpClient from "effect/unstable/http/HttpClient";
18import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
19import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
20
21import packageJson from "../../package.json" with { type: "json" };
22import * as ServerConfig from "../config.ts";
23import { getTelemetryIdentifier } from "./Identify.ts";
24
25interface BufferedAnalyticsEvent {
26 readonly event: string;
27 readonly properties?: Readonly<Record<string, unknown>>;
28 readonly capturedAt: string;
29}
30
31const TelemetryEnvConfig = Config.all({
32 posthogKey: Config.string("T3CODE_POSTHOG_KEY").pipe(
33 Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"),
34 ),
35 posthogHost: Config.string("T3CODE_POSTHOG_HOST").pipe(
36 Config.withDefault("https://us.i.posthog.com"),
37 ),
38 enabled: Config.boolean("T3CODE_TELEMETRY_ENABLED").pipe(Config.withDefault(true)),
39 flushBatchSize: Config.number("T3CODE_TELEMETRY_FLUSH_BATCH_SIZE").pipe(Config.withDefault(20)),
40 maxBufferedEvents: Config.number("T3CODE_TELEMETRY_MAX_BUFFERED_EVENTS").pipe(
41 Config.withDefault(1_000),
42 ),
43 wslDistroName: Config.string("WSL_DISTRO_NAME").pipe(Config.option),
44});
45
46export class AnalyticsService extends Context.Service<
47 AnalyticsService,
48 {
49 /** Record an anonymous event for best-effort buffered delivery. */
50 readonly record: (
51 event: string,
52 properties?: Readonly<Record<string, unknown>>,
53 ) => Effect.Effect<void>;
54
55 /** Flush all currently queued telemetry events. */
56 readonly flush: Effect.Effect<void>;
57 }
58>()("t3/telemetry/AnalyticsService") {
59 /** No-op layer for callers that intentionally disable telemetry. */
60 static readonly layerTest = Layer.succeed(
61 AnalyticsService,
62 AnalyticsService.of({
63 record: () => Effect.void,
64 flush: Effect.void,
65 }),
66 );
67}
68
69export const make = Effect.gen(function* () {
70 const telemetryConfig = yield* TelemetryEnvConfig;
71 const httpClient = yield* HttpClient.HttpClient;
72 const serverConfig = yield* ServerConfig.ServerConfig;
73 const identifier = yield* getTelemetryIdentifier;
74 const bufferRef = yield* Ref.make<ReadonlyArray<BufferedAnalyticsEvent>>([]);
75 const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client";
76 const hostPlatform = yield* HostProcessPlatform;
77 const hostArchitecture = yield* HostProcessArchitecture;
78
79 const enqueueBufferedEvent = (event: string, properties?: Readonly<Record<string, unknown>>) =>
80 Effect.flatMap(DateTime.now, (now) =>
81 Ref.modify(bufferRef, (current) => {
82 const appended = [
83 ...current,
84 {
85 event,
86 ...(properties ? { properties } : {}),
87 capturedAt: DateTime.formatIso(now),
88 } satisfies BufferedAnalyticsEvent,
89 ];
90
91 const next =
92 appended.length > telemetryConfig.maxBufferedEvents
93 ? appended.slice(appended.length - telemetryConfig.maxBufferedEvents)
94 : appended;
95
96 return [
97 {
98 size: next.length,
99 dropped: next.length !== appended.length,
100 } as const,
101 next,
102 ] as const;
103 }),
104 );
105
106 const sendBatch = Effect.fn("AnalyticsService.sendBatch")(function* (
107 events: ReadonlyArray<BufferedAnalyticsEvent>,
108 ) {
109 if (!telemetryConfig.enabled || !identifier) return;
110
111 const payload = {
112 api_key: telemetryConfig.posthogKey,
113 batch: events.map((event) => ({
114 event: event.event,
115 distinct_id: identifier,
116 properties: {
117 ...event.properties,
118 $process_person_profile: false,
119 platform: hostPlatform,
120 wsl: Option.getOrUndefined(telemetryConfig.wslDistroName),
121 arch: hostArchitecture,
122 t3CodeVersion: packageJson.version,
123 clientType,
124 },
125 timestamp: event.capturedAt,
126 })),
127 };
128
129 yield* HttpClientRequest.post(`${telemetryConfig.posthogHost}/batch/`).pipe(
130 HttpClientRequest.bodyJson(payload),
131 Effect.flatMap(httpClient.execute),
132 Effect.flatMap(HttpClientResponse.filterStatusOk),
133 );
134 });
135
136 const flush: AnalyticsService["Service"]["flush"] = Effect.gen(function* () {
137 while (true) {
138 const batch = yield* Ref.modify(bufferRef, (current) => {
139 if (current.length === 0) {
140 return [[] as ReadonlyArray<BufferedAnalyticsEvent>, current] as const;
141 }
142 const nextBatch = current.slice(0, telemetryConfig.flushBatchSize);
143 const remaining = current.slice(nextBatch.length);
144 return [nextBatch, remaining] as const;
145 });
146
147 if (batch.length === 0) {
148 return;
149 }
150
151 yield* sendBatch(batch).pipe(
152 Effect.catch((error) =>
153 Ref.update(bufferRef, (current) => [...batch, ...current]).pipe(
154 Effect.flatMap(() => Effect.fail(error)),
155 ),
156 ),
157 );
158 }
159 }).pipe(Effect.catch((cause) => Effect.logError("Failed to flush telemetry", { cause })));
160
161 const record: AnalyticsService["Service"]["record"] = Effect.fn("AnalyticsService.record")(
162 function* (event, properties) {
163 if (!telemetryConfig.enabled || !identifier) return;
164
165 const enqueueResult = yield* enqueueBufferedEvent(event, properties);
166 if (enqueueResult.dropped) {
167 yield* Effect.logDebug("analytics buffer full; dropping oldest event", {
168 size: enqueueResult.size,
169 event,
170 });
171 }
172 },
173 );
174
175 yield* Effect.forever(Effect.sleep(1000).pipe(Effect.flatMap(() => flush)), {
176 disableYield: true,
177 }).pipe(Effect.forkScoped);
178
179 yield* Effect.addFinalizer(() => flush);
180
181 return AnalyticsService.of({ record, flush });
182});
183
184export const layer = Layer.effect(AnalyticsService, make);
185
186export const layerTest = AnalyticsService.layerTest;
apps/server/src/telemetry/Identify.ts+12-6
......@@ -6,7 +6,7 @@ import * as FileSystem from "effect/FileSystem";
66import * as Path from "effect/Path";
77import * as Schema from "effect/Schema";
88
9import { ServerConfig } from "../config.ts";
9import * as ServerConfig from "../config.ts";
1010
1111const CodexAuthJsonSchema = Schema.Struct({
1212 tokens: Schema.Struct({
......@@ -19,9 +19,14 @@ const ClaudeJsonSchema = Schema.Struct({
1919});
2020
2121class IdentifyUserError extends Schema.TaggedErrorClass<IdentifyUserError>()("IdentifyUserError", {
22 message: Schema.String,
23 cause: Schema.optional(Schema.Defect()),
24}) {}
22 operation: Schema.Literal("hash_identifier"),
23 algorithm: Schema.Literal("SHA-256"),
24 cause: Schema.Defect(),
25}) {
26 override get message(): string {
27 return `Failed to hash telemetry identifier with ${this.algorithm}.`;
28 }
29}
2530
2631const hash = (value: string) =>
2732 Crypto.Crypto.pipe(
......@@ -30,7 +35,8 @@ const hash = (value: string) =>
3035 Effect.mapError(
3136 (cause) =>
3237 new IdentifyUserError({
33 message: "Failed to hash identifier",
38 operation: "hash_identifier",
39 algorithm: "SHA-256",
3440 cause,
3541 }),
3642 ),
......@@ -64,7 +70,7 @@ const getClaudeUserId = Effect.gen(function* () {
6470
6571const upsertAnonymousId = Effect.gen(function* () {
6672 const fileSystem = yield* FileSystem.FileSystem;
67 const { anonymousIdPath } = yield* ServerConfig;
73 const { anonymousIdPath } = yield* ServerConfig.ServerConfig;
6874
6975 const anonymousId = yield* fileSystem.readFileString(anonymousIdPath).pipe(
7076 Effect.catch(() =>
apps/server/src/telemetry/Layers/AnalyticsService.test.ts deleted-121
......@@ -1,121 +0,0 @@
1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as HttpServer from "effect/unstable/http/HttpServer";
8import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
10
11import { ServerConfig } from "../../config.ts";
12import { getTelemetryIdentifier } from "../Identify.ts";
13import { AnalyticsService } from "../Services/AnalyticsService.ts";
14import { AnalyticsServiceLayerLive } from "./AnalyticsService.ts";
15
16interface RecordedBatchRequest {
17 readonly path: string;
18 readonly body: {
19 readonly batch?: ReadonlyArray<{
20 readonly event?: string;
21 readonly properties?: {
22 readonly index?: number;
23 readonly clientType?: string;
24 };
25 }>;
26 } | null;
27}
28
29interface RecordedBatchBody {
30 readonly batch: ReadonlyArray<{
31 readonly event?: string;
32 readonly properties?: {
33 readonly index?: number;
34 readonly clientType?: string;
35 };
36 }>;
37}
38
39it.layer(NodeServices.layer)("AnalyticsService test", (it) => {
40 it.effect("flush drains all buffered events across multiple batches", () =>
41 Effect.gen(function* () {
42 const capturedRequests: Array<RecordedBatchRequest> = [];
43 const serverConfigLayer = ServerConfig.layerTest(process.cwd(), {
44 prefix: "t3-telemetry-base-",
45 });
46
47 const telemetryLayer = AnalyticsServiceLayerLive.pipe(Layer.provideMerge(serverConfigLayer));
48 const configLayer = ConfigProvider.layer(
49 ConfigProvider.fromUnknown({
50 T3CODE_TELEMETRY_ENABLED: true,
51 T3CODE_POSTHOG_KEY: "phc_test_key",
52 T3CODE_POSTHOG_HOST: "",
53 T3CODE_TELEMETRY_FLUSH_BATCH_SIZE: 20,
54 }),
55 );
56 const batchServerLayer = HttpServer.serve(
57 Effect.gen(function* () {
58 const request = yield* HttpServerRequest.HttpServerRequest;
59 if (request.method !== "POST") {
60 return HttpServerResponse.empty({ status: 404 });
61 }
62
63 const payload = yield* request.json.pipe(
64 Effect.map((body) => body as RecordedBatchRequest["body"]),
65 Effect.orElseSucceed(() => null),
66 );
67
68 capturedRequests.push({ path: request.url, body: payload });
69
70 return HttpServerResponse.jsonUnsafe({});
71 }),
72 );
73 const runtimeLayer = telemetryLayer.pipe(
74 Layer.provide(configLayer),
75 Layer.provideMerge(NodeHttpServer.layerTest),
76 );
77
78 yield* Effect.gen(function* () {
79 yield* Layer.launch(batchServerLayer).pipe(Effect.forkScoped);
80 const telemetryIdentifier = yield* getTelemetryIdentifier;
81 assert.equal(telemetryIdentifier !== null, true);
82 const analytics = yield* AnalyticsService;
83
84 for (let index = 0; index < 45; index += 1) {
85 yield* analytics.record("test.flush.drain", { index });
86 }
87
88 yield* analytics.flush;
89 }).pipe(Effect.provide(runtimeLayer));
90
91 const batchRequests = capturedRequests.filter(
92 (request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } =>
93 Array.isArray(request.body?.batch),
94 );
95 assert.equal(batchRequests.length, 3);
96 assert.equal(
97 batchRequests.every((request) => request.path === "/batch/" || request.path === "/batch"),
98 true,
99 );
100 const deliveredIndexes = batchRequests.flatMap((request) =>
101 request.body.batch
102 .filter((event) => event.event === "test.flush.drain")
103 .map((event) => event.properties?.index)
104 .filter((index): index is number => typeof index === "number"),
105 );
106
107 const sorted = deliveredIndexes.toSorted((a, b) => a - b);
108 assert.equal(sorted.length, 45);
109 assert.deepEqual(
110 sorted,
111 Array.from({ length: 45 }, (_, index) => index),
112 );
113 assert.equal(
114 batchRequests.every((request) =>
115 request.body.batch.every((event) => event.properties?.clientType === "cli-web-client"),
116 ),
117 true,
118 );
119 }),
120 );
121});
apps/server/src/telemetry/Layers/AnalyticsService.ts deleted-163
......@@ -1,163 +0,0 @@
1/**
2 * AnalyticsServiceLive - Anonymous PostHog telemetry layer.
3 *
4 * Persists a random installation-scoped anonymous id to state dir, buffers
5 * events in memory, and flushes batches to PostHog over Effect HttpClient.
6 *
7 * @module AnalyticsServiceLive
8 */
9
10import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
11import * as Config from "effect/Config";
12import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Ref from "effect/Ref";
17import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
18
19import { ServerConfig } from "../../config.ts";
20import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts";
21import { getTelemetryIdentifier } from "../Identify.ts";
22import packageJson from "../../../package.json" with { type: "json" };
23
24interface BufferedAnalyticsEvent {
25 readonly event: string;
26 readonly properties?: Readonly<Record<string, unknown>>;
27 readonly capturedAt: string;
28}
29
30const TelemetryEnvConfig = Config.all({
31 posthogKey: Config.string("T3CODE_POSTHOG_KEY").pipe(
32 Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"),
33 ),
34 posthogHost: Config.string("T3CODE_POSTHOG_HOST").pipe(
35 Config.withDefault("https://us.i.posthog.com"),
36 ),
37 enabled: Config.boolean("T3CODE_TELEMETRY_ENABLED").pipe(Config.withDefault(true)),
38 flushBatchSize: Config.number("T3CODE_TELEMETRY_FLUSH_BATCH_SIZE").pipe(Config.withDefault(20)),
39 maxBufferedEvents: Config.number("T3CODE_TELEMETRY_MAX_BUFFERED_EVENTS").pipe(
40 Config.withDefault(1_000),
41 ),
42 wslDistroName: Config.string("WSL_DISTRO_NAME").pipe(Config.option),
43});
44
45const makeAnalyticsService = Effect.gen(function* () {
46 const telemetryConfig = yield* TelemetryEnvConfig;
47 const httpClient = yield* HttpClient.HttpClient;
48 const serverConfig = yield* ServerConfig;
49 const identifier = yield* getTelemetryIdentifier;
50 const bufferRef = yield* Ref.make<ReadonlyArray<BufferedAnalyticsEvent>>([]);
51 const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client";
52 const hostPlatform = yield* HostProcessPlatform;
53 const hostArchitecture = yield* HostProcessArchitecture;
54
55 const enqueueBufferedEvent = (event: string, properties?: Readonly<Record<string, unknown>>) =>
56 Effect.flatMap(DateTime.now, (now) =>
57 Ref.modify(bufferRef, (current) => {
58 const appended = [
59 ...current,
60 {
61 event,
62 ...(properties ? { properties } : {}),
63 capturedAt: DateTime.formatIso(now),
64 } satisfies BufferedAnalyticsEvent,
65 ];
66
67 const next =
68 appended.length > telemetryConfig.maxBufferedEvents
69 ? appended.slice(appended.length - telemetryConfig.maxBufferedEvents)
70 : appended;
71
72 return [
73 {
74 size: next.length,
75 dropped: next.length !== appended.length,
76 } as const,
77 next,
78 ] as const;
79 }),
80 );
81
82 const sendBatch = Effect.fn("sendBatch")(function* (
83 events: ReadonlyArray<BufferedAnalyticsEvent>,
84 ) {
85 if (!telemetryConfig.enabled || !identifier) return;
86
87 const payload = {
88 api_key: telemetryConfig.posthogKey,
89 batch: events.map((event) => ({
90 event: event.event,
91 distinct_id: identifier,
92 properties: {
93 ...event.properties,
94 $process_person_profile: false,
95 platform: hostPlatform,
96 wsl: Option.getOrUndefined(telemetryConfig.wslDistroName),
97 arch: hostArchitecture,
98 t3CodeVersion: packageJson.version,
99 clientType,
100 },
101 timestamp: event.capturedAt,
102 })),
103 };
104
105 yield* HttpClientRequest.post(`${telemetryConfig.posthogHost}/batch/`).pipe(
106 HttpClientRequest.bodyJson(payload),
107 Effect.flatMap(httpClient.execute),
108 Effect.flatMap(HttpClientResponse.filterStatusOk),
109 );
110 });
111
112 const flush: AnalyticsServiceShape["flush"] = Effect.gen(function* () {
113 while (true) {
114 const batch = yield* Ref.modify(bufferRef, (current) => {
115 if (current.length === 0) {
116 return [[] as ReadonlyArray<BufferedAnalyticsEvent>, current] as const;
117 }
118 const nextBatch = current.slice(0, telemetryConfig.flushBatchSize);
119 const remaining = current.slice(nextBatch.length);
120 return [nextBatch, remaining] as const;
121 });
122
123 if (batch.length === 0) {
124 return;
125 }
126
127 yield* sendBatch(batch).pipe(
128 Effect.catch((error) =>
129 Ref.update(bufferRef, (current) => [...batch, ...current]).pipe(
130 Effect.flatMap(() => Effect.fail(error)),
131 ),
132 ),
133 );
134 }
135 }).pipe(Effect.catch((cause) => Effect.logError("Failed to flush telemetry", { cause })));
136
137 const record: AnalyticsServiceShape["record"] = Effect.fn("record")(
138 function* (event, properties) {
139 if (!telemetryConfig.enabled || !identifier) return;
140
141 const enqueueResult = yield* enqueueBufferedEvent(event, properties);
142 if (enqueueResult.dropped) {
143 yield* Effect.logDebug("analytics buffer full; dropping oldest event", {
144 size: enqueueResult.size,
145 event,
146 });
147 }
148 },
149 );
150
151 yield* Effect.forever(Effect.sleep(1000).pipe(Effect.flatMap(() => flush)), {
152 disableYield: true,
153 }).pipe(Effect.forkScoped);
154
155 yield* Effect.addFinalizer(() => flush);
156
157 return {
158 record,
159 flush,
160 } satisfies AnalyticsServiceShape;
161});
162
163export const AnalyticsServiceLayerLive = Layer.effect(AnalyticsService, makeAnalyticsService);
apps/server/src/telemetry/Services/AnalyticsService.ts+2-35
......@@ -1,35 +1,2 @@
1/**
2 * AnalyticsService - Anonymous telemetry capture contract.
3 *
4 * Provides a best-effort event API for runtime telemetry and a strict
5 * `captureImmediate` method for call sites that need explicit error handling.
6 *
7 * @module AnalyticsService
8 */
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Context from "effect/Context";
12
13export interface AnalyticsServiceShape {
14 /**
15 * Capture an event immediately; returns typed failure when capture fails.
16 */
17 readonly record: (
18 event: string,
19 properties?: Readonly<Record<string, unknown>>,
20 ) => Effect.Effect<void, never>;
21
22 /**
23 * Flush queued telemetry.
24 */
25 readonly flush: Effect.Effect<void, never>;
26}
27
28export class AnalyticsService extends Context.Service<AnalyticsService, AnalyticsServiceShape>()(
29 "t3/telemetry/Services/AnalyticsService",
30) {
31 static readonly layerTest = Layer.succeed(AnalyticsService, {
32 record: () => Effect.void,
33 flush: Effect.void,
34 });
35}
1// Compatibility shim for the intentionally excluded orchestration harness.
2export { AnalyticsService } from "../AnalyticsService.ts";