614 lines · 22.2 KB · TypeScript
RawDownload
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.