import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability";import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";import * as Context from "effect/Context";import * as DateTime from "effect/DateTime";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Logger from "effect/Logger";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as PlatformError from "effect/PlatformError";import * as References from "effect/References";import * as Ref from "effect/Ref";import * as Schema from "effect/Schema";import * as Scope from "effect/Scope";import * as Semaphore from "effect/Semaphore";import * as SynchronizedRef from "effect/SynchronizedRef";import * as Tracer from "effect/Tracer";import { OtlpExporter, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024;const DESKTOP_LOG_FILE_MAX_FILES = 10;const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child";const DESKTOP_TRACE_BATCH_WINDOW_MS = 1_000;const DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES = 1024 * 1024;const DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_CHUNKS = 256; export interface RotatingLogFileWriter { readonly writeBytes: (chunk: Uint8Array) => Effect.Effect<void>; readonly writeText: (chunk: string) => Effect.Effect<void>;} export interface DesktopBackendOutputLogShape { readonly beginSession: (input: { readonly details: string }) => Effect.Effect<void>; readonly writeOutputChunk: ( streamName: "stdout" | "stderr", chunk: Uint8Array, ) => Effect.Effect<void>; readonly persistFailureSnapshot: (input: { readonly details: string }) => Effect.Effect<void>; readonly persistFailure: (input: { readonly details: string }) => Effect.Effect<void>; readonly discardSession: Effect.Effect<void>;} // Factory for per-instance backend output logs. `forInstance(id)` returns// a writer that targets a distinct rotating log file — the primary// instance keeps `server-child.log` so the historical path stays stable// for ops; other instances get `server-child-<sanitized-id>.log`.//// Writers are cached per id within a single factory instance so repeated// `forInstance` calls (e.g. during a backend restart that re-resolves// services) reuse the same rotating writer rather than racing each other// on the same file.export class DesktopBackendOutputLogFactory extends Context.Service< DesktopBackendOutputLogFactory, { readonly forInstance: (id: string) => Effect.Effect<DesktopBackendOutputLogShape>; }>()("@t3tools/desktop/app/DesktopObservability/DesktopBackendOutputLogFactory") {} const textEncoder = new TextEncoder();const textDecoder = new TextDecoder(); export type DesktopLogAnnotations = Record<string, unknown>; export interface DesktopComponentLogger { readonly annotate: <A, E, R>( effect: Effect.Effect<A, E, R>, annotations?: DesktopLogAnnotations, ) => Effect.Effect<A, E, R>; readonly logDebug: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect<void>; readonly logInfo: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect<void>; readonly logWarning: ( message: string, annotations?: DesktopLogAnnotations, ) => Effect.Effect<void>; readonly logError: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect<void>;} export function makeComponentLogger(component: string): DesktopComponentLogger { const annotate: DesktopComponentLogger["annotate"] = (effect, annotations) => effect.pipe( Effect.annotateLogs({ component, ...annotations, }), ); return { annotate, logDebug: (message, annotations) => annotate(Effect.logDebug(message), annotations), logInfo: (message, annotations) => annotate(Effect.logInfo(message), annotations), logWarning: (message, annotations) => annotate(Effect.logWarning(message), annotations), logError: (message, annotations) => annotate(Effect.logError(message), annotations), };} class DesktopLogFileWriterConfigurationError extends Schema.TaggedError<DesktopLogFileWriterConfigurationError>()( "DesktopLogFileWriterConfigurationError", { option: Schema.Literals(["maxBytes", "maxFiles"]), value: Schema.Number, },) { override get message() { return `${this.option} must be >= 1 (received ${this.value})`; }} type DesktopLogFileWriterError = | DesktopLogFileWriterConfigurationError | PlatformError.PlatformError; const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim(); const DesktopBackendChildLogRecord = Schema.Struct({ message: Schema.String, level: Schema.Literals(["INFO", "ERROR"]), timestamp: Schema.String, annotations: Schema.Record(Schema.String, Schema.Unknown), spans: Schema.Record(Schema.String, Schema.Unknown), fiberId: Schema.String,}); const encodeDesktopBackendChildLogRecord = Schema.encodeEffect( Schema.fromJsonString(DesktopBackendChildLogRecord),); const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = { beginSession: () => Effect.void, writeOutputChunk: () => Effect.void, persistFailureSnapshot: () => Effect.void, persistFailure: () => Effect.void, discardSession: Effect.void,}; interface BufferedBackendOutputChunk { readonly streamName: "stdout" | "stderr"; readonly chunk: Uint8Array; readonly offset: number;} interface BackendOutputSession { readonly runId: string; readonly startDetails: string; readonly chunks: ReadonlyArray<BufferedBackendOutputChunk>; readonly byteLength: number;} export function appendBoundedOutputChunk( session: BackendOutputSession, streamName: "stdout" | "stderr", chunk: Uint8Array,): BackendOutputSession { if (chunk.byteLength === 0) { return session; } const retainedChunk = chunk.byteLength > DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES ? chunk.slice(chunk.byteLength - DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES) : chunk.slice(); const chunks = [...session.chunks, { streamName, chunk: retainedChunk, offset: 0 }]; let byteLength = session.byteLength + retainedChunk.byteLength; let overflow = Math.max(0, byteLength - DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES); let firstRetainedIndex = 0; while (overflow > 0) { const first = chunks[firstRetainedIndex]; if (!first) break; const retainedByteLength = first.chunk.byteLength - first.offset; if (retainedByteLength <= overflow) { overflow -= retainedByteLength; byteLength -= retainedByteLength; firstRetainedIndex += 1; continue; } chunks[firstRetainedIndex] = { ...first, offset: first.offset + overflow, }; byteLength -= overflow; overflow = 0; } const excessChunks = Math.max( 0, chunks.length - firstRetainedIndex - DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_CHUNKS, ); for (let index = firstRetainedIndex; index < firstRetainedIndex + excessChunks; index += 1) { const chunk = chunks[index]; byteLength -= chunk ? chunk.chunk.byteLength - chunk.offset : 0; } firstRetainedIndex += excessChunks; return { ...session, chunks: chunks.slice(firstRetainedIndex), byteLength, };} const currentDesktopRunId = Effect.gen(function* () { const annotations = yield* References.CurrentLogAnnotations; const runId = annotations.runId; return typeof runId === "string" && runId.length > 0 ? runId : "unknown";}); const refreshFileSize = ( fileSystem: FileSystem.FileSystem, filePath: string,): Effect.Effect<number, never> => fileSystem.stat(filePath).pipe( Effect.map((stat) => Number(stat.size)), Effect.orElseSucceed(() => 0), ); const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { readonly filePath: string; readonly maxBytes?: number; readonly maxFiles?: number;}): Effect.fn.Return< RotatingLogFileWriter, DesktopLogFileWriterError, FileSystem.FileSystem | Path.Path> { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const maxBytes = input.maxBytes ?? DESKTOP_LOG_FILE_MAX_BYTES; const maxFiles = input.maxFiles ?? DESKTOP_LOG_FILE_MAX_FILES; const directory = path.dirname(input.filePath); const baseName = path.basename(input.filePath); if (maxBytes < 1) { return yield* new DesktopLogFileWriterConfigurationError({ option: "maxBytes", value: maxBytes, }); } if (maxFiles < 1) { return yield* new DesktopLogFileWriterConfigurationError({ option: "maxFiles", value: maxFiles, }); } yield* fileSystem.makeDirectory(directory, { recursive: true }); const withSuffix = (index: number) => `${input.filePath}.${index}`; const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath)); const mutex = yield* Semaphore.make(1); const pruneOverflowBackups = Effect.gen(function* () { const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); for (const entry of entries) { if (!entry.startsWith(`${baseName}.`)) continue; const suffix = Number(entry.slice(baseName.length + 1)); if (!Number.isInteger(suffix) || suffix <= maxFiles) continue; yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore); } }); const rotate = Effect.gen(function* () { yield* fileSystem.remove(withSuffix(maxFiles), { force: true }).pipe(Effect.ignore); for (let index = maxFiles - 1; index >= 1; index -= 1) { const source = withSuffix(index); const sourceExists = yield* fileSystem.exists(source).pipe(Effect.orElseSucceed(() => false)); if (sourceExists) { yield* fileSystem.rename(source, withSuffix(index + 1)); } } const currentExists = yield* fileSystem .exists(input.filePath) .pipe(Effect.orElseSucceed(() => false)); if (currentExists) { yield* fileSystem.rename(input.filePath, withSuffix(1)); } yield* Ref.set(currentSize, 0); }).pipe( Effect.catch(() => refreshFileSize(fileSystem, input.filePath).pipe( Effect.flatMap((size) => Ref.set(currentSize, size)), ), ), ); const writeBytes = (chunk: Uint8Array): Effect.Effect<void> => { if (chunk.byteLength === 0) return Effect.void; return mutex.withPermits(1)( Effect.gen(function* () { const beforeSize = yield* Ref.get(currentSize); if (beforeSize > 0 && beforeSize + chunk.byteLength > maxBytes) { yield* rotate; } yield* fileSystem.writeFile(input.filePath, chunk, { flag: "a" }); const afterSize = (yield* Ref.get(currentSize)) + chunk.byteLength; yield* Ref.set(currentSize, afterSize); if (afterSize > maxBytes) { yield* rotate; } }).pipe( Effect.catch(() => refreshFileSize(fileSystem, input.filePath).pipe( Effect.flatMap((size) => Ref.set(currentSize, size)), ), ), ), ); }; yield* pruneOverflowBackups; return { writeBytes, writeText: (chunk) => writeBytes(textEncoder.encode(chunk)), } satisfies RotatingLogFileWriter;}); const readPersistedOtlpTracesUrl: Effect.Effect< Option.Option<string>, never, FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment> = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); if (Option.isNone(raw)) { return Option.none(); } const parsed = parsePersistedServerObservabilitySettings(raw.value); return Option.fromNullishOr(parsed.otlpTracesUrl);}); const resolveOtlpTracesUrl = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; if (Option.isSome(environment.otlpTracesUrl)) { return environment.otlpTracesUrl; } return yield* readPersistedOtlpTracesUrl;}); const writeDevelopmentConsoleOutput = ( streamName: "stdout" | "stderr", chunk: Uint8Array,): Effect.Effect<void> => Effect.sync(() => { const output = streamName === "stderr" ? process.stderr : process.stdout; output.write(chunk); }).pipe(Effect.ignore); const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")( function* ( logFile: RotatingLogFileWriter, input: { readonly message: string; readonly level: "INFO" | "ERROR"; readonly annotations: Record<string, unknown>; }, ): Effect.fn.Return<void> { return yield* Effect.gen(function* () { const timestamp = DateTime.formatIso(yield* DateTime.now); const encoded = yield* encodeDesktopBackendChildLogRecord({ message: input.message, level: input.level, timestamp, annotations: input.annotations, spans: {}, fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID, }); yield* logFile.writeText(`${encoded}\n`); }).pipe(Effect.ignore({ log: true })); },); const PRIMARY_BACKEND_LOG_INSTANCE_ID = PRIMARY_LOCAL_ENVIRONMENT_ID; const sanitizeInstanceIdForFileName = (id: string): string => id.replace(/[^a-zA-Z0-9._-]+/g, "_"); const backendLogFilePathForInstance = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], id: string,): string => { // Primary keeps the historical "server-child.log" path so ops scripts // and packaged-build log inspection still find it where it always lived. if (id === PRIMARY_BACKEND_LOG_INSTANCE_ID) { return environment.path.join(environment.logDir, "server-child.log"); } const sanitized = sanitizeInstanceIdForFileName(id); return environment.path.join(environment.logDir, `server-child-${sanitized}.log`);}; // Just the IO sink. Cacheable by resolved file path so two ids that// sanitize to the same filename share a single RotatingLogFileWriter// (no race on currentSize tracking). Splitting the sink off from the// per-call shape lets the shape annotate writes with the *caller's*// id rather than whatever id created the cached writer first.const makeBackendOutputSinkForInstance = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], id: string,): Effect.Effect< Option.Option<RotatingLogFileWriter>, never, FileSystem.FileSystem | Path.Path | Scope.Scope> => makeRotatingLogFileWriter({ filePath: backendLogFilePathForInstance(environment, id), }).pipe(Effect.option); const makeBackendOutputLogShape = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], id: string, sink: Option.Option<RotatingLogFileWriter>,): Effect.Effect<DesktopBackendOutputLogShape> => Option.match(sink, { onNone: () => Effect.succeed(DesktopBackendOutputLogNoop), onSome: (logFile) => Effect.gen(function* () { const sessionRef = yield* Ref.make(Option.none<BackendOutputSession>()); const writeFailure = Effect.fn("desktop.observability.backendOutput.writeFailure")( function* (session: BackendOutputSession, details: string) { yield* writeBackendChildLogRecord(logFile, { message: "backend child process failure output start", level: "ERROR", annotations: { component: "desktop-backend-child", runId: session.runId, instanceId: id, phase: "START", details: session.startDetails, }, }); for (const output of session.chunks) { yield* writeBackendChildLogRecord(logFile, { message: "backend child process output", level: output.streamName === "stderr" ? "ERROR" : "INFO", annotations: { component: "desktop-backend-child", runId: session.runId, instanceId: id, stream: output.streamName, text: textDecoder.decode(output.chunk.subarray(output.offset)), }, }); } yield* writeBackendChildLogRecord(logFile, { message: "backend child process failure output end", level: "ERROR", annotations: { component: "desktop-backend-child", runId: session.runId, instanceId: id, phase: "END", details: sanitizeLogValue(details), }, }); }, ); return { beginSession: Effect.fn("desktop.observability.backendOutput.beginSession")(function* ({ details, }) { const runId = yield* currentDesktopRunId; yield* Ref.set( sessionRef, Option.some({ runId, startDetails: sanitizeLogValue(details), chunks: [], byteLength: 0, }), ); }), writeOutputChunk: Effect.fnUntraced(function* (streamName, chunk) { if (environment.isDevelopment) { yield* writeDevelopmentConsoleOutput(streamName, chunk); } yield* Ref.update( sessionRef, Option.map((session) => appendBoundedOutputChunk(session, streamName, chunk)), ); }), persistFailureSnapshot: Effect.fn( "desktop.observability.backendOutput.persistFailureSnapshot", )(function* ({ details }) { const session = yield* Ref.get(sessionRef); if (Option.isSome(session)) { yield* writeFailure(session.value, details); } }), persistFailure: Effect.fn("desktop.observability.backendOutput.persistFailure")( function* ({ details }) { const session = yield* Ref.modify(sessionRef, (current) => [current, Option.none()]); if (Option.isNone(session)) return;Lines 1–500 of 614. Load more
import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as References from "effect/References"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as Tracer from "effect/Tracer"; import { OtlpExporter, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; const DESKTOP_LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; const DESKTOP_LOG_FILE_MAX_FILES = 10; const DESKTOP_BACKEND_CHILD_LOG_FIBER_ID = "#backend-child"; const DESKTOP_TRACE_BATCH_WINDOW_MS = 1_000; const DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES = 1024 * 1024; const DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_CHUNKS = 256; export interface RotatingLogFileWriter { readonly writeBytes: (chunk: Uint8Array) => Effect.Effect<void>; readonly writeText: (chunk: string) => Effect.Effect<void>; } export interface DesktopBackendOutputLogShape { readonly beginSession: (input: { readonly details: string }) => Effect.Effect<void>; readonly writeOutputChunk: ( streamName: "stdout" | "stderr", chunk: Uint8Array, ) => Effect.Effect<void>; readonly persistFailureSnapshot: (input: { readonly details: string }) => Effect.Effect<void>; readonly persistFailure: (input: { readonly details: string }) => Effect.Effect<void>; readonly discardSession: Effect.Effect<void>; } // Factory for per-instance backend output logs. `forInstance(id)` returns // a writer that targets a distinct rotating log file — the primary // instance keeps `server-child.log` so the historical path stays stable // for ops; other instances get `server-child-<sanitized-id>.log`. // // Writers are cached per id within a single factory instance so repeated // `forInstance` calls (e.g. during a backend restart that re-resolves // services) reuse the same rotating writer rather than racing each other // on the same file. export class DesktopBackendOutputLogFactory extends Context.Service< DesktopBackendOutputLogFactory, { readonly forInstance: (id: string) => Effect.Effect<DesktopBackendOutputLogShape>; } >()("@t3tools/desktop/app/DesktopObservability/DesktopBackendOutputLogFactory") {} const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); export type DesktopLogAnnotations = Record<string, unknown>; export interface DesktopComponentLogger { readonly annotate: <A, E, R>( effect: Effect.Effect<A, E, R>, annotations?: DesktopLogAnnotations, ) => Effect.Effect<A, E, R>; readonly logDebug: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect<void>; readonly logInfo: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect<void>; readonly logWarning: ( message: string, annotations?: DesktopLogAnnotations, ) => Effect.Effect<void>; readonly logError: (message: string, annotations?: DesktopLogAnnotations) => Effect.Effect<void>; } export function makeComponentLogger(component: string): DesktopComponentLogger { const annotate: DesktopComponentLogger["annotate"] = (effect, annotations) => effect.pipe( Effect.annotateLogs({ component, ...annotations, }), ); return { annotate, logDebug: (message, annotations) => annotate(Effect.logDebug(message), annotations), logInfo: (message, annotations) => annotate(Effect.logInfo(message), annotations), logWarning: (message, annotations) => annotate(Effect.logWarning(message), annotations), logError: (message, annotations) => annotate(Effect.logError(message), annotations), }; } class DesktopLogFileWriterConfigurationError extends Schema.TaggedError<DesktopLogFileWriterConfigurationError>()( "DesktopLogFileWriterConfigurationError", { option: Schema.Literals(["maxBytes", "maxFiles"]), value: Schema.Number, }, ) { override get message() { return `${this.option} must be >= 1 (received ${this.value})`; } } type DesktopLogFileWriterError = | DesktopLogFileWriterConfigurationError | PlatformError.PlatformError; const sanitizeLogValue = (value: string): string => value.replace(/\s+/g, " ").trim(); const DesktopBackendChildLogRecord = Schema.Struct({ message: Schema.String, level: Schema.Literals(["INFO", "ERROR"]), timestamp: Schema.String, annotations: Schema.Record(Schema.String, Schema.Unknown), spans: Schema.Record(Schema.String, Schema.Unknown), fiberId: Schema.String, }); const encodeDesktopBackendChildLogRecord = Schema.encodeEffect( Schema.fromJsonString(DesktopBackendChildLogRecord), ); const DesktopBackendOutputLogNoop: DesktopBackendOutputLogShape = { beginSession: () => Effect.void, writeOutputChunk: () => Effect.void, persistFailureSnapshot: () => Effect.void, persistFailure: () => Effect.void, discardSession: Effect.void, }; interface BufferedBackendOutputChunk { readonly streamName: "stdout" | "stderr"; readonly chunk: Uint8Array; readonly offset: number; } interface BackendOutputSession { readonly runId: string; readonly startDetails: string; readonly chunks: ReadonlyArray<BufferedBackendOutputChunk>; readonly byteLength: number; } export function appendBoundedOutputChunk( session: BackendOutputSession, streamName: "stdout" | "stderr", chunk: Uint8Array, ): BackendOutputSession { if (chunk.byteLength === 0) { return session; } const retainedChunk = chunk.byteLength > DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES ? chunk.slice(chunk.byteLength - DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES) : chunk.slice(); const chunks = [...session.chunks, { streamName, chunk: retainedChunk, offset: 0 }]; let byteLength = session.byteLength + retainedChunk.byteLength; let overflow = Math.max(0, byteLength - DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_BYTES); let firstRetainedIndex = 0; while (overflow > 0) { const first = chunks[firstRetainedIndex]; if (!first) break; const retainedByteLength = first.chunk.byteLength - first.offset; if (retainedByteLength <= overflow) { overflow -= retainedByteLength; byteLength -= retainedByteLength; firstRetainedIndex += 1; continue; } chunks[firstRetainedIndex] = { ...first, offset: first.offset + overflow, }; byteLength -= overflow; overflow = 0; } const excessChunks = Math.max( 0, chunks.length - firstRetainedIndex - DESKTOP_BACKEND_OUTPUT_BUFFER_MAX_CHUNKS, ); for (let index = firstRetainedIndex; index < firstRetainedIndex + excessChunks; index += 1) { const chunk = chunks[index]; byteLength -= chunk ? chunk.chunk.byteLength - chunk.offset : 0; } firstRetainedIndex += excessChunks; return { ...session, chunks: chunks.slice(firstRetainedIndex), byteLength, }; } const currentDesktopRunId = Effect.gen(function* () { const annotations = yield* References.CurrentLogAnnotations; const runId = annotations.runId; return typeof runId === "string" && runId.length > 0 ? runId : "unknown"; }); const refreshFileSize = ( fileSystem: FileSystem.FileSystem, filePath: string, ): Effect.Effect<number, never> => fileSystem.stat(filePath).pipe( Effect.map((stat) => Number(stat.size)), Effect.orElseSucceed(() => 0), ); const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")(function* (input: { readonly filePath: string; readonly maxBytes?: number; readonly maxFiles?: number; }): Effect.fn.Return< RotatingLogFileWriter, DesktopLogFileWriterError, FileSystem.FileSystem | Path.Path > { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const maxBytes = input.maxBytes ?? DESKTOP_LOG_FILE_MAX_BYTES; const maxFiles = input.maxFiles ?? DESKTOP_LOG_FILE_MAX_FILES; const directory = path.dirname(input.filePath); const baseName = path.basename(input.filePath); if (maxBytes < 1) { return yield* new DesktopLogFileWriterConfigurationError({ option: "maxBytes", value: maxBytes, }); } if (maxFiles < 1) { return yield* new DesktopLogFileWriterConfigurationError({ option: "maxFiles", value: maxFiles, }); } yield* fileSystem.makeDirectory(directory, { recursive: true }); const withSuffix = (index: number) => `${input.filePath}.${index}`; const currentSize = yield* Ref.make(yield* refreshFileSize(fileSystem, input.filePath)); const mutex = yield* Semaphore.make(1); const pruneOverflowBackups = Effect.gen(function* () { const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); for (const entry of entries) { if (!entry.startsWith(`${baseName}.`)) continue; const suffix = Number(entry.slice(baseName.length + 1)); if (!Number.isInteger(suffix) || suffix <= maxFiles) continue; yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore); } }); const rotate = Effect.gen(function* () { yield* fileSystem.remove(withSuffix(maxFiles), { force: true }).pipe(Effect.ignore); for (let index = maxFiles - 1; index >= 1; index -= 1) { const source = withSuffix(index); const sourceExists = yield* fileSystem.exists(source).pipe(Effect.orElseSucceed(() => false)); if (sourceExists) { yield* fileSystem.rename(source, withSuffix(index + 1)); } } const currentExists = yield* fileSystem .exists(input.filePath) .pipe(Effect.orElseSucceed(() => false)); if (currentExists) { yield* fileSystem.rename(input.filePath, withSuffix(1)); } yield* Ref.set(currentSize, 0); }).pipe( Effect.catch(() => refreshFileSize(fileSystem, input.filePath).pipe( Effect.flatMap((size) => Ref.set(currentSize, size)), ), ), ); const writeBytes = (chunk: Uint8Array): Effect.Effect<void> => { if (chunk.byteLength === 0) return Effect.void; return mutex.withPermits(1)( Effect.gen(function* () { const beforeSize = yield* Ref.get(currentSize); if (beforeSize > 0 && beforeSize + chunk.byteLength > maxBytes) { yield* rotate; } yield* fileSystem.writeFile(input.filePath, chunk, { flag: "a" }); const afterSize = (yield* Ref.get(currentSize)) + chunk.byteLength; yield* Ref.set(currentSize, afterSize); if (afterSize > maxBytes) { yield* rotate; } }).pipe( Effect.catch(() => refreshFileSize(fileSystem, input.filePath).pipe( Effect.flatMap((size) => Ref.set(currentSize, size)), ), ), ), ); }; yield* pruneOverflowBackups; return { writeBytes, writeText: (chunk) => writeBytes(textEncoder.encode(chunk)), } satisfies RotatingLogFileWriter; }); const readPersistedOtlpTracesUrl: Effect.Effect< Option.Option<string>, never, FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment > = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe(Effect.option); if (Option.isNone(raw)) { return Option.none(); } const parsed = parsePersistedServerObservabilitySettings(raw.value); return Option.fromNullishOr(parsed.otlpTracesUrl); }); const resolveOtlpTracesUrl = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; if (Option.isSome(environment.otlpTracesUrl)) { return environment.otlpTracesUrl; } return yield* readPersistedOtlpTracesUrl; }); const writeDevelopmentConsoleOutput = ( streamName: "stdout" | "stderr", chunk: Uint8Array, ): Effect.Effect<void> => Effect.sync(() => { const output = streamName === "stderr" ? process.stderr : process.stdout; output.write(chunk); }).pipe(Effect.ignore); const writeBackendChildLogRecord = Effect.fn("desktop.observability.writeBackendChildLogRecord")( function* ( logFile: RotatingLogFileWriter, input: { readonly message: string; readonly level: "INFO" | "ERROR"; readonly annotations: Record<string, unknown>; }, ): Effect.fn.Return<void> { return yield* Effect.gen(function* () { const timestamp = DateTime.formatIso(yield* DateTime.now); const encoded = yield* encodeDesktopBackendChildLogRecord({ message: input.message, level: input.level, timestamp, annotations: input.annotations, spans: {}, fiberId: DESKTOP_BACKEND_CHILD_LOG_FIBER_ID, }); yield* logFile.writeText(`${encoded}\n`); }).pipe(Effect.ignore({ log: true })); }, ); const PRIMARY_BACKEND_LOG_INSTANCE_ID = PRIMARY_LOCAL_ENVIRONMENT_ID; const sanitizeInstanceIdForFileName = (id: string): string => id.replace(/[^a-zA-Z0-9._-]+/g, "_"); const backendLogFilePathForInstance = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], id: string, ): string => { // Primary keeps the historical "server-child.log" path so ops scripts // and packaged-build log inspection still find it where it always lived. if (id === PRIMARY_BACKEND_LOG_INSTANCE_ID) { return environment.path.join(environment.logDir, "server-child.log"); } const sanitized = sanitizeInstanceIdForFileName(id); return environment.path.join(environment.logDir, `server-child-${sanitized}.log`); }; // Just the IO sink. Cacheable by resolved file path so two ids that // sanitize to the same filename share a single RotatingLogFileWriter // (no race on currentSize tracking). Splitting the sink off from the // per-call shape lets the shape annotate writes with the *caller's* // id rather than whatever id created the cached writer first. const makeBackendOutputSinkForInstance = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], id: string, ): Effect.Effect< Option.Option<RotatingLogFileWriter>, never, FileSystem.FileSystem | Path.Path | Scope.Scope > => makeRotatingLogFileWriter({ filePath: backendLogFilePathForInstance(environment, id), }).pipe(Effect.option); const makeBackendOutputLogShape = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], id: string, sink: Option.Option<RotatingLogFileWriter>, ): Effect.Effect<DesktopBackendOutputLogShape> => Option.match(sink, { onNone: () => Effect.succeed(DesktopBackendOutputLogNoop), onSome: (logFile) => Effect.gen(function* () { const sessionRef = yield* Ref.make(Option.none<BackendOutputSession>()); const writeFailure = Effect.fn("desktop.observability.backendOutput.writeFailure")( function* (session: BackendOutputSession, details: string) { yield* writeBackendChildLogRecord(logFile, { message: "backend child process failure output start", level: "ERROR", annotations: { component: "desktop-backend-child", runId: session.runId, instanceId: id, phase: "START", details: session.startDetails, }, }); for (const output of session.chunks) { yield* writeBackendChildLogRecord(logFile, { message: "backend child process output", level: output.streamName === "stderr" ? "ERROR" : "INFO", annotations: { component: "desktop-backend-child", runId: session.runId, instanceId: id, stream: output.streamName, text: textDecoder.decode(output.chunk.subarray(output.offset)), }, }); } yield* writeBackendChildLogRecord(logFile, { message: "backend child process failure output end", level: "ERROR", annotations: { component: "desktop-backend-child", runId: session.runId, instanceId: id, phase: "END", details: sanitizeLogValue(details), }, }); }, ); return { beginSession: Effect.fn("desktop.observability.backendOutput.beginSession")(function* ({ details, }) { const runId = yield* currentDesktopRunId; yield* Ref.set( sessionRef, Option.some({ runId, startDetails: sanitizeLogValue(details), chunks: [], byteLength: 0, }), ); }), writeOutputChunk: Effect.fnUntraced(function* (streamName, chunk) { if (environment.isDevelopment) { yield* writeDevelopmentConsoleOutput(streamName, chunk); } yield* Ref.update( sessionRef, Option.map((session) => appendBoundedOutputChunk(session, streamName, chunk)), ); }), persistFailureSnapshot: Effect.fn( "desktop.observability.backendOutput.persistFailureSnapshot", )(function* ({ details }) { const session = yield* Ref.get(sessionRef); if (Option.isSome(session)) { yield* writeFailure(session.value, details); } }), persistFailure: Effect.fn("desktop.observability.backendOutput.persistFailure")( function* ({ details }) { const session = yield* Ref.modify(sessionRef, (current) => [current, Option.none()]); if (Option.isNone(session)) return;