Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 75 additions & 2 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import Mime from "@effect/platform-node/Mime";
import { Effect, FileSystem, Option, Path } from "effect";
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
import { Data, Effect, FileSystem, Layer, Option, Path } from "effect";
import { cast } from "effect/Function";
import {
HttpBody,
HttpClient,
HttpClientResponse,
HttpRouter,
HttpServerResponse,
HttpServerRequest,
} from "effect/unstable/http";
import { OtlpTracer } from "effect/unstable/observability";

import {
ATTACHMENTS_ROUTE_PREFIX,
Expand All @@ -9,10 +18,74 @@ import {
} from "./attachmentPaths";
import { resolveAttachmentPathById } from "./attachmentStore";
import { ServerConfig } from "./config";
import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts";
import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts";
import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver";

const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";
const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`;
const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";

class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{
readonly cause: unknown;
readonly bodyJson: OtlpTracer.TraceData;
}> {}

export const otlpTracesProxyRouteLayer = HttpRouter.add(
"POST",
OTLP_TRACES_PROXY_PATH,
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
const config = yield* ServerConfig;
const otlpTracesUrl = config.otlpTracesUrl;
const browserTraceCollector = yield* BrowserTraceCollector;
const httpClient = yield* HttpClient.HttpClient;
const bodyJson = cast<unknown, OtlpTracer.TraceData>(yield* request.json);

yield* Effect.try({
try: () => decodeOtlpTraceRecords(bodyJson),
catch: (cause) => new DecodeOtlpTraceRecordsError({ cause, bodyJson }),
}).pipe(
Effect.flatMap((records) => browserTraceCollector.record(records)),
Effect.catch((cause) =>
Effect.logWarning("Failed to decode browser OTLP traces", {
cause,
bodyJson,
}),
),
);

if (otlpTracesUrl === undefined) {
return HttpServerResponse.empty({ status: 204 });
}

return yield* httpClient
.post(otlpTracesUrl, {
body: HttpBody.jsonUnsafe(bodyJson),
})
.pipe(
Effect.flatMap(HttpClientResponse.filterStatusOk),
Effect.as(HttpServerResponse.empty({ status: 204 })),
Effect.tapError((cause) =>
Effect.logWarning("Failed to export browser OTLP traces", {
cause,
otlpTracesUrl,
}),
),
Effect.catch(() =>
Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })),
),
);
}),
).pipe(
Layer.provide(
HttpRouter.cors({
allowedMethods: ["POST", "OPTIONS"],
allowedHeaders: ["content-type"],
maxAge: 600,
}),
),
);

export const attachmentsRouteLayer = HttpRouter.add(
"GET",
Expand Down
26 changes: 23 additions & 3 deletions apps/server/src/observability/Layers/Observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/obse
import { ServerConfig } from "../../config.ts";
import { ServerLoggerLive } from "../../serverLogger.ts";
import { makeLocalFileTracer } from "../LocalFileTracer.ts";
import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts";
import { makeTraceSink } from "../TraceSink.ts";

const otlpSerializationLayer = OtlpSerialization.layerJson;

Expand All @@ -16,9 +18,14 @@ export const ObservabilityLive = Layer.unwrap(
Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled),
);

const tracerLayer = Layer.effect(
Tracer.Tracer,
const tracerLayer = Layer.unwrap(
Effect.gen(function* () {
const sink = yield* makeTraceSink({
filePath: config.serverTracePath,
maxBytes: config.traceMaxBytes,
maxFiles: config.traceMaxFiles,
batchWindowMs: config.traceBatchWindowMs,
});
const delegate =
config.otlpTracesUrl === undefined
? undefined
Expand All @@ -34,13 +41,26 @@ export const ObservabilityLive = Layer.unwrap(
},
});

return yield* makeLocalFileTracer({
const tracer = yield* makeLocalFileTracer({
filePath: config.serverTracePath,
maxBytes: config.traceMaxBytes,
maxFiles: config.traceMaxFiles,
batchWindowMs: config.traceBatchWindowMs,
sink,
...(delegate ? { delegate } : {}),
});

return Layer.mergeAll(
Layer.succeed(Tracer.Tracer, tracer),
Layer.succeed(BrowserTraceCollector, {
record: (records) =>
Effect.sync(() => {
for (const record of records) {
sink.push(record);
}
}),
}),
);
}),
).pipe(Layer.provideMerge(otlpSerializationLayer));

Expand Down
6 changes: 3 additions & 3 deletions apps/server/src/observability/LocalFileTracer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import path from "node:path";
import { assert, describe, it } from "@effect/vitest";
import { Effect, Layer, Logger, References, Tracer } from "effect";

import type { TraceRecord } from "./TraceRecord.ts";
import type { EffectTraceRecord } from "./TraceRecord.ts";
import { makeLocalFileTracer } from "./LocalFileTracer.ts";

const makeTestLayer = (tracePath: string) =>
Expand All @@ -23,13 +23,13 @@ const makeTestLayer = (tracePath: string) =>
Layer.succeed(References.MinimumLogLevel, "Info"),
);

const readTraceRecords = (tracePath: string): Array<TraceRecord> =>
const readTraceRecords = (tracePath: string): Array<EffectTraceRecord> =>
fs
.readFileSync(tracePath, "utf8")
.trim()
.split("\n")
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line) as TraceRecord);
.map((line) => JSON.parse(line) as EffectTraceRecord);

describe("LocalFileTracer", () => {
it.effect("writes nested spans to disk and captures log messages as span events", () =>
Expand Down
21 changes: 12 additions & 9 deletions apps/server/src/observability/LocalFileTracer.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import type * as Exit from "effect/Exit";
import { Effect, Option, Tracer } from "effect";

import { spanToTraceRecord } from "./TraceRecord.ts";
import { makeTraceSink } from "./TraceSink.ts";
import { EffectTraceRecord, spanToTraceRecord } from "./TraceRecord.ts";
import { makeTraceSink, type TraceSink } from "./TraceSink.ts";

export interface LocalFileTracerOptions {
readonly filePath: string;
readonly maxBytes: number;
readonly maxFiles: number;
readonly batchWindowMs: number;
readonly delegate?: Tracer.Tracer;
readonly sink?: TraceSink;
}

class LocalFileSpan implements Tracer.Span {
Expand All @@ -30,7 +31,7 @@ class LocalFileSpan implements Tracer.Span {
constructor(
options: Parameters<Tracer.Tracer["span"]>[0],
private readonly delegate: Tracer.Span,
private readonly push: (record: ReturnType<typeof spanToTraceRecord>) => void,
private readonly push: (record: EffectTraceRecord) => void,
) {
this.name = delegate.name;
this.spanId = delegate.spanId;
Expand Down Expand Up @@ -82,12 +83,14 @@ class LocalFileSpan implements Tracer.Span {
export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* (
options: LocalFileTracerOptions,
) {
const sink = yield* makeTraceSink({
filePath: options.filePath,
maxBytes: options.maxBytes,
maxFiles: options.maxFiles,
batchWindowMs: options.batchWindowMs,
});
const sink =
options.sink ??
(yield* makeTraceSink({
filePath: options.filePath,
maxBytes: options.maxBytes,
maxFiles: options.maxFiles,
batchWindowMs: options.batchWindowMs,
}));

const delegate =
options.delegate ??
Expand Down
13 changes: 13 additions & 0 deletions apps/server/src/observability/Services/BrowserTraceCollector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ServiceMap } from "effect";
import type { Effect } from "effect";

import type { TraceRecord } from "../TraceRecord.ts";

export interface BrowserTraceCollectorShape {
readonly record: (records: ReadonlyArray<TraceRecord>) => Effect.Effect<void>;
}

export class BrowserTraceCollector extends ServiceMap.Service<
BrowserTraceCollector,
BrowserTraceCollectorShape
>()("t3/observability/Services/BrowserTraceCollector") {}
Loading
Loading