From d33838ae4de2a2602387a899f0a344352500a7b2 Mon Sep 17 00:00:00 2001 From: Christoph Schmatzler Date: Thu, 14 May 2026 19:14:25 +0000 Subject: [PATCH 1/7] Clean up api request handling --- apps/api/src/index.ts | 137 +++++++++--------- apps/web/src/components/header.tsx | 7 +- .../web/src/pages/$organization._settings.tsx | 1 - 3 files changed, 69 insertions(+), 76 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7f355d4..44ee910 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -22,88 +22,87 @@ import { Database } from "@leuchtturm/core/database"; import { InternalServerError } from "@leuchtturm/core/errors"; import { ZeroDatabase } from "@leuchtturm/zero/zero-database"; -const apiRoutes = Layer.mergeAll( - HttpApiBuilder.layer(Contract.Api, { - openapiPath: "/open-api", - }).pipe( - Layer.provide( - Layer.mergeAll( - HealthHandler.layer(Contract.Api), - BillingHandler.layer(Contract.Api), - ZeroHandler.layer(Contract.Api), - AuthHandler.layer(Contract.Api), +namespace Api { + const routes = Layer.mergeAll( + HttpApiBuilder.layer(Contract.Api, { + openapiPath: "/open-api", + }).pipe( + Layer.provide( + Layer.mergeAll( + HealthHandler.layer(Contract.Api), + BillingHandler.layer(Contract.Api), + ZeroHandler.layer(Contract.Api), + AuthHandler.layer(Contract.Api), + ), ), + Layer.provide(AuthMiddleware.layer), ), - Layer.provide(AuthMiddleware.layer), - ), - DocsHandler.layer, -); + DocsHandler.layer, + ); -const { handler } = HttpEffect.toWebHandlerLayer( - HttpRouter.toHttpEffect(apiRoutes).pipe(Effect.flatten), - HttpServer.layerServices, - { - middleware: (app) => - HttpMiddleware.cors({ - allowedOrigins: (origin) => { - if (!origin) return false; - if (Resource.App.stage !== "prod") return true; + const { handler } = HttpEffect.toWebHandlerLayer( + HttpRouter.toHttpEffect(routes).pipe(Effect.flatten), + HttpServer.layerServices, + { + middleware: (app) => + app.pipe( + HttpMiddleware.logger, + Observability.middleware, + RequestContext.middleware, + HttpMiddleware.cors({ + allowedOrigins: (origin) => { + if (!origin) return false; + if (Resource.App.stage !== "prod") return true; - return origin === `https://${Resource.Dns.AppDomain}`; - }, - credentials: true, - })(RequestContext.middleware(Observability.middleware(HttpMiddleware.logger(app)))), - }, -); + return origin === `https://${Resource.Dns.AppDomain}`; + }, + credentials: true, + }), + ), + }, + ); -const handleRequest = Effect.fn("handleRequest")(function* ( - request: Request, - executionContext: Pick, -) { - const baseContext = RequestContext.make(request, executionContext); + export const handleRequest = Effect.fn("handleRequest")(function* ( + request: Request, + executionContext: Pick, + ) { + const requestContext = RequestContext.make(request, executionContext); - return yield* Observability.withRequestContext(baseContext)( - Effect.scoped( + return yield* Observability.withRequestContext(requestContext)( Effect.gen(function* () { - const observabilityContext = yield* Effect.context(); - const servicesContext = yield* Layer.build( - Layer.mergeAll(Auth.defaultLayer, ZeroDatabase.layer).pipe( - Layer.provideMerge(Database.layer(Resource.Database.connectionString)), - ), - ); - const requestContext = Context.merge( - observabilityContext, - Context.merge(baseContext, servicesContext), + const services = yield* Layer.mergeAll(Auth.defaultLayer, ZeroDatabase.layer).pipe( + Layer.provideMerge(Database.layer(Resource.Database.connectionString)), + Layer.build, ); + const context = Context.merge(requestContext, services); - return yield* Effect.provideContext( - Effect.tryPromise({ - try: () => handler(request, requestContext), - catch: (cause) => cause, - }).pipe( - Effect.tap((response) => - Effect.annotateCurrentSpan({ "http.response.status_code": response.status }), - ), - Effect.mapError(() => new InternalServerError()), + return yield* Effect.tryPromise({ + try: () => handler(request, context), + catch: (cause) => cause, + }).pipe( + Effect.tap((response) => + Effect.annotateCurrentSpan({ "http.response.status_code": response.status }), ), - requestContext, + Effect.mapError(() => new InternalServerError()), + Effect.provideContext(context), ); - }), - ).pipe( - Effect.withSpan(`${request.method} ${request.url}`, { - attributes: { - "http.request.method": request.method, - "url.full": request.url, - }, - kind: "server", - root: true, - }), - ), - ); -}); + }).pipe( + Effect.scoped, + Effect.withSpan(`${request.method} ${request.url}`, { + attributes: { + "http.request.method": request.method, + "url.full": request.url, + }, + kind: "server", + root: true, + }), + ), + ); + }); +} export default wrapCloudflareHandler({ fetch(request: Request, _env: unknown, ctx: ExecutionContext) { - return Effect.runPromise(handleRequest(request, ctx)); + return Effect.runPromise(Api.handleRequest(request, ctx)); }, }); diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 388921a..498159d 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -4,7 +4,6 @@ import { GearIcon } from "@phosphor-icons/react/Gear"; import { OptionIcon } from "@phosphor-icons/react/Option"; import { PlusIcon } from "@phosphor-icons/react/Plus"; import { SignOutIcon } from "@phosphor-icons/react/SignOut"; -import { SparkleIcon } from "@phosphor-icons/react/Sparkle"; import { UserIcon } from "@phosphor-icons/react/User"; import { useQuery } from "@tanstack/react-query"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; @@ -70,11 +69,7 @@ export function Header({ return (
- + Leuchtturm
diff --git a/apps/web/src/pages/$organization._settings.tsx b/apps/web/src/pages/$organization._settings.tsx index 4ecb6a5..732bb7d 100644 --- a/apps/web/src/pages/$organization._settings.tsx +++ b/apps/web/src/pages/$organization._settings.tsx @@ -22,7 +22,6 @@ import { SidebarGroup, SidebarGroupContent, SidebarGroupLabel, - SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuButton, From d7cecc9ddfec6391dec778c97a96f184e184bd97 Mon Sep 17 00:00:00 2001 From: Christoph Schmatzler Date: Thu, 14 May 2026 19:24:46 +0000 Subject: [PATCH 2/7] Fix api observability context --- apps/api/src/index.ts | 2 +- apps/api/src/observability.ts | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 44ee910..8d1f112 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -68,7 +68,7 @@ namespace Api { ) { const requestContext = RequestContext.make(request, executionContext); - return yield* Observability.withRequestContext(requestContext)( + return yield* Observability.withRequestContext(requestContext)((requestContext) => Effect.gen(function* () { const services = yield* Layer.mergeAll(Auth.defaultLayer, ZeroDatabase.layer).pipe( Layer.provideMerge(Database.layer(Resource.Database.connectionString)), diff --git a/apps/api/src/observability.ts b/apps/api/src/observability.ts index 28ef688..29fc044 100644 --- a/apps/api/src/observability.ts +++ b/apps/api/src/observability.ts @@ -23,16 +23,18 @@ export namespace Observability { }); export function withRequestContext(requestContext: Context.Context) { - return (effect: Effect.Effect) => + return ( + use: (context: Context.Context) => Effect.Effect, + ) => Effect.acquireUseRelease( Scope.make(), (observabilityScope) => Layer.buildWithScope(layer, observabilityScope).pipe( - Effect.flatMap((observabilityContext) => - effect.pipe( - Effect.provideContext(Context.merge(requestContext, observabilityContext)), - ), - ), + Effect.flatMap((observabilityContext) => { + const context = Context.merge(requestContext, observabilityContext); + + return use(context).pipe(Effect.provideContext(context)); + }), ), (observabilityScope) => Effect.sync(() => { From a383dc6ef9d816272888a4f87fd8fa75ea22b67c Mon Sep 17 00:00:00 2001 From: Christoph Schmatzler Date: Thu, 14 May 2026 19:40:11 +0000 Subject: [PATCH 3/7] Refactor api observability context --- apps/api/src/execution-context.ts | 20 +++++ apps/api/src/index.ts | 47 +++++------ apps/api/src/middleware/request-context.ts | 4 +- apps/api/src/observability.ts | 90 ++++++++++------------ apps/api/src/product-analytics.ts | 8 +- 5 files changed, 91 insertions(+), 78 deletions(-) create mode 100644 apps/api/src/execution-context.ts diff --git a/apps/api/src/execution-context.ts b/apps/api/src/execution-context.ts new file mode 100644 index 0000000..5d1f23d --- /dev/null +++ b/apps/api/src/execution-context.ts @@ -0,0 +1,20 @@ +import * as Context from "effect/Context"; + +export namespace ExecutionContext { + export interface Interface { + readonly waitUntil: (promise: Promise) => void; + } + + export class Service extends Context.Service()( + "@leuchtturm/api/ExecutionContext", + ) {} + + export function make(executionContext: Interface) { + return Context.make( + Service, + Service.of({ + waitUntil: executionContext.waitUntil.bind(executionContext), + }), + ); + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8d1f112..7e239e7 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -9,6 +9,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { Resource, wrapCloudflareHandler } from "sst/resource/cloudflare"; import { Contract } from "@leuchtturm/api/contract"; +import { ExecutionContext } from "@leuchtturm/api/execution-context"; import { AuthHandler } from "@leuchtturm/api/handlers/auth/index"; import { BillingHandler } from "@leuchtturm/api/handlers/billing/index"; import { DocsHandler } from "@leuchtturm/api/handlers/docs/index"; @@ -64,30 +65,29 @@ namespace Api { export const handleRequest = Effect.fn("handleRequest")(function* ( request: Request, - executionContext: Pick, + executionContext: ExecutionContext.Interface, ) { - const requestContext = RequestContext.make(request, executionContext); + const context = Context.merge( + RequestContext.make(request), + ExecutionContext.make(executionContext), + ); - return yield* Observability.withRequestContext(requestContext)((requestContext) => - Effect.gen(function* () { - const services = yield* Layer.mergeAll(Auth.defaultLayer, ZeroDatabase.layer).pipe( - Layer.provideMerge(Database.layer(Resource.Database.connectionString)), - Layer.build, - ); - const context = Context.merge(requestContext, services); + return yield* Effect.gen(function* () { + const services = yield* Layer.mergeAll( + Observability.layer, + Auth.defaultLayer, + ZeroDatabase.layer, + ).pipe(Layer.provideMerge(Database.layer(Resource.Database.connectionString)), Layer.build); + const handlerContext = Context.merge(context, services); - return yield* Effect.tryPromise({ - try: () => handler(request, context), - catch: (cause) => cause, - }).pipe( - Effect.tap((response) => - Effect.annotateCurrentSpan({ "http.response.status_code": response.status }), - ), - Effect.mapError(() => new InternalServerError()), - Effect.provideContext(context), - ); + return yield* Effect.tryPromise({ + try: () => handler(request, handlerContext), + catch: (cause) => cause, }).pipe( - Effect.scoped, + Effect.tap((response) => + Effect.annotateCurrentSpan({ "http.response.status_code": response.status }), + ), + Effect.mapError(() => new InternalServerError()), Effect.withSpan(`${request.method} ${request.url}`, { attributes: { "http.request.method": request.method, @@ -96,13 +96,14 @@ namespace Api { kind: "server", root: true, }), - ), - ); + Effect.provideContext(handlerContext), + ); + }).pipe(Effect.scoped, Effect.provideContext(context)); }); } export default wrapCloudflareHandler({ - fetch(request: Request, _env: unknown, ctx: ExecutionContext) { + fetch(request: Request, _env: unknown, ctx: globalThis.ExecutionContext) { return Effect.runPromise(Api.handleRequest(request, ctx)); }, }); diff --git a/apps/api/src/middleware/request-context.ts b/apps/api/src/middleware/request-context.ts index bb240e5..7b5dd92 100644 --- a/apps/api/src/middleware/request-context.ts +++ b/apps/api/src/middleware/request-context.ts @@ -11,14 +11,13 @@ export namespace RequestContext { readonly method: string; readonly path: string; readonly requestId: string; - readonly waitUntil: ExecutionContext["waitUntil"]; } export class Service extends Context.Service()( "@leuchtturm/api/RequestContext", ) {} - export function make(request: Request, executionContext: Pick) { + export function make(request: Request) { const requestId = Option.fromNullishOr(request.headers.get("x-request-id")).pipe( Option.map((value) => value.trim()), Option.flatMap(Schema.decodeUnknownOption(Schema.String.check(Schema.isUUID(4)))), @@ -31,7 +30,6 @@ export namespace RequestContext { method: request.method, path: request.url, requestId, - waitUntil: executionContext.waitUntil.bind(executionContext), }), ); } diff --git a/apps/api/src/observability.ts b/apps/api/src/observability.ts index 29fc044..4e14f24 100644 --- a/apps/api/src/observability.ts +++ b/apps/api/src/observability.ts @@ -1,5 +1,4 @@ import * as Clock from "effect/Clock"; -import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -13,7 +12,7 @@ import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as Otlp from "effect/unstable/observability/Otlp"; import { Resource } from "sst/resource/cloudflare"; -import { RequestContext } from "@leuchtturm/api/middleware/request-context"; +import { ExecutionContext } from "@leuchtturm/api/execution-context"; export namespace Observability { export const requestDurationBoundaries = Metric.exponentialBoundaries({ @@ -22,29 +21,6 @@ export namespace Observability { count: 35, }); - export function withRequestContext(requestContext: Context.Context) { - return ( - use: (context: Context.Context) => Effect.Effect, - ) => - Effect.acquireUseRelease( - Scope.make(), - (observabilityScope) => - Layer.buildWithScope(layer, observabilityScope).pipe( - Effect.flatMap((observabilityContext) => { - const context = Context.merge(requestContext, observabilityContext); - - return use(context).pipe(Effect.provideContext(context)); - }), - ), - (observabilityScope) => - Effect.sync(() => { - Context.get(requestContext, RequestContext.Service).waitUntil( - Effect.runPromise(Scope.close(observabilityScope, Exit.void)), - ); - }), - ); - } - export function recordAction(action: string, result: "success" | "failure") { return Metric.update( Metric.counter("api_action_total", { @@ -99,29 +75,47 @@ export namespace Observability { }), ); - export const layer = Layer.mergeAll( - Otlp.layerProtobuf({ - baseUrl: Resource.GrafanaOtlpConfig.url, - headers: { Authorization: Resource.GrafanaOtlpConfig.authorization }, - loggerExcludeLogSpans: false, - loggerMergeWithExisting: true, - resource: { - serviceName: "leuchtturm-api", - attributes: { - "service.namespace": "leuchtturm", - app: "leuchtturm", - stage: Resource.App.stage, - }, + export const otlpLayer = Otlp.layerProtobuf({ + baseUrl: Resource.GrafanaOtlpConfig.url, + headers: { Authorization: Resource.GrafanaOtlpConfig.authorization }, + loggerExcludeLogSpans: false, + loggerMergeWithExisting: true, + resource: { + serviceName: "leuchtturm-api", + attributes: { + "service.namespace": "leuchtturm", + app: "leuchtturm", + stage: Resource.App.stage, }, - shutdownTimeout: "3 seconds", - }).pipe(Layer.provide(FetchHttpClient.layer)), - Layer.succeed( - HttpMiddleware.SpanNameGenerator, - (request: HttpServerRequest.HttpServerRequest) => - `${request.method} ${Option.getOrElse( - Option.map(HttpServerRequest.toURL(request), (url) => url.pathname), - () => request.url, - )}`, - ), + }, + shutdownTimeout: "3 seconds", + }).pipe(Layer.provide(FetchHttpClient.layer)); + + export const deferredOtlpShutdownLayer = Layer.effectContext( + Effect.gen(function* () { + const executionContext = yield* ExecutionContext.Service; + const scope = yield* Scope.Scope; + const observabilityScope = yield* Scope.make(); + + yield* Scope.addFinalizer( + scope, + Effect.sync(() => { + executionContext.waitUntil(Effect.runPromise(Scope.close(observabilityScope, Exit.void))); + }), + ); + + return yield* Layer.buildWithScope(otlpLayer, observabilityScope); + }), ); + + export const spanNameGeneratorLayer = Layer.succeed( + HttpMiddleware.SpanNameGenerator, + (request: HttpServerRequest.HttpServerRequest) => + `${request.method} ${Option.getOrElse( + Option.map(HttpServerRequest.toURL(request), (url) => url.pathname), + () => request.url, + )}`, + ); + + export const layer = Layer.mergeAll(deferredOtlpShutdownLayer, spanNameGeneratorLayer); } diff --git a/apps/api/src/product-analytics.ts b/apps/api/src/product-analytics.ts index 95b9c81..cd91d33 100644 --- a/apps/api/src/product-analytics.ts +++ b/apps/api/src/product-analytics.ts @@ -2,7 +2,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { RequestContext } from "@leuchtturm/api/middleware/request-context"; +import { ExecutionContext } from "@leuchtturm/api/execution-context"; import { Posthog } from "@leuchtturm/api/posthog"; export namespace ProductAnalytics { @@ -11,7 +11,7 @@ export namespace ProductAnalytics { distinctId: string, event: string, properties?: Record, - ) => Effect.Effect; + ) => Effect.Effect; } export class Service extends Context.Service()( @@ -25,10 +25,10 @@ export namespace ProductAnalytics { return Service.of({ capture: (distinctId, event, properties = {}) => Effect.gen(function* () { - const context = yield* RequestContext.Service; + const executionContext = yield* ExecutionContext.Service; yield* Effect.sync(() => { - context.waitUntil( + executionContext.waitUntil( client.captureImmediate({ distinctId, event, From 02ac7fd4ef02fb588d1d70e5a468a3b98715792f Mon Sep 17 00:00:00 2001 From: Christoph Schmatzler Date: Thu, 14 May 2026 19:43:11 +0000 Subject: [PATCH 4/7] Inline api observability layers --- apps/api/src/observability.ts | 82 ++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 40 deletions(-) diff --git a/apps/api/src/observability.ts b/apps/api/src/observability.ts index 4e14f24..c7f55a1 100644 --- a/apps/api/src/observability.ts +++ b/apps/api/src/observability.ts @@ -75,47 +75,49 @@ export namespace Observability { }), ); - export const otlpLayer = Otlp.layerProtobuf({ - baseUrl: Resource.GrafanaOtlpConfig.url, - headers: { Authorization: Resource.GrafanaOtlpConfig.authorization }, - loggerExcludeLogSpans: false, - loggerMergeWithExisting: true, - resource: { - serviceName: "leuchtturm-api", - attributes: { - "service.namespace": "leuchtturm", - app: "leuchtturm", - stage: Resource.App.stage, - }, - }, - shutdownTimeout: "3 seconds", - }).pipe(Layer.provide(FetchHttpClient.layer)); + export const layer = Layer.mergeAll( + Layer.effectContext( + Effect.gen(function* () { + const executionContext = yield* ExecutionContext.Service; + const scope = yield* Scope.Scope; + const observabilityScope = yield* Scope.make(); - export const deferredOtlpShutdownLayer = Layer.effectContext( - Effect.gen(function* () { - const executionContext = yield* ExecutionContext.Service; - const scope = yield* Scope.Scope; - const observabilityScope = yield* Scope.make(); + yield* Scope.addFinalizer( + scope, + Effect.sync(() => { + executionContext.waitUntil( + Effect.runPromise(Scope.close(observabilityScope, Exit.void)), + ); + }), + ); - yield* Scope.addFinalizer( - scope, - Effect.sync(() => { - executionContext.waitUntil(Effect.runPromise(Scope.close(observabilityScope, Exit.void))); - }), - ); - - return yield* Layer.buildWithScope(otlpLayer, observabilityScope); - }), - ); - - export const spanNameGeneratorLayer = Layer.succeed( - HttpMiddleware.SpanNameGenerator, - (request: HttpServerRequest.HttpServerRequest) => - `${request.method} ${Option.getOrElse( - Option.map(HttpServerRequest.toURL(request), (url) => url.pathname), - () => request.url, - )}`, + return yield* Layer.buildWithScope( + Otlp.layerProtobuf({ + baseUrl: Resource.GrafanaOtlpConfig.url, + headers: { Authorization: Resource.GrafanaOtlpConfig.authorization }, + loggerExcludeLogSpans: false, + loggerMergeWithExisting: true, + resource: { + serviceName: "leuchtturm-api", + attributes: { + "service.namespace": "leuchtturm", + app: "leuchtturm", + stage: Resource.App.stage, + }, + }, + shutdownTimeout: "3 seconds", + }).pipe(Layer.provide(FetchHttpClient.layer)), + observabilityScope, + ); + }), + ), + Layer.succeed( + HttpMiddleware.SpanNameGenerator, + (request: HttpServerRequest.HttpServerRequest) => + `${request.method} ${Option.getOrElse( + Option.map(HttpServerRequest.toURL(request), (url) => url.pathname), + () => request.url, + )}`, + ), ); - - export const layer = Layer.mergeAll(deferredOtlpShutdownLayer, spanNameGeneratorLayer); } From c7a03e56f243b22991ad35c7c5a751018daf666b Mon Sep 17 00:00:00 2001 From: Christoph Schmatzler Date: Fri, 15 May 2026 09:50:11 +0000 Subject: [PATCH 5/7] Fix api trace attribution --- apps/api/src/index.ts | 19 ++-- apps/api/src/observability.ts | 160 +++++++++++++++++++--------------- 2 files changed, 98 insertions(+), 81 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7e239e7..9c1f8d4 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -48,7 +48,6 @@ namespace Api { middleware: (app) => app.pipe( HttpMiddleware.logger, - Observability.middleware, RequestContext.middleware, HttpMiddleware.cors({ allowedOrigins: (origin) => { @@ -59,6 +58,7 @@ namespace Api { }, credentials: true, }), + Observability.middleware, ), }, ); @@ -78,24 +78,19 @@ namespace Api { Auth.defaultLayer, ZeroDatabase.layer, ).pipe(Layer.provideMerge(Database.layer(Resource.Database.connectionString)), Layer.build); + const observability = Context.get(services, Observability.Service); const handlerContext = Context.merge(context, services); return yield* Effect.tryPromise({ try: () => handler(request, handlerContext), catch: (cause) => cause, }).pipe( - Effect.tap((response) => - Effect.annotateCurrentSpan({ "http.response.status_code": response.status }), - ), Effect.mapError(() => new InternalServerError()), - Effect.withSpan(`${request.method} ${request.url}`, { - attributes: { - "http.request.method": request.method, - "url.full": request.url, - }, - kind: "server", - root: true, - }), + Effect.ensuring( + Effect.sync(() => { + executionContext.waitUntil(Effect.runPromise(observability.flush)); + }), + ), Effect.provideContext(handlerContext), ); }).pipe(Effect.scoped, Effect.provideContext(context)); diff --git a/apps/api/src/observability.ts b/apps/api/src/observability.ts index c7f55a1..e6c7949 100644 --- a/apps/api/src/observability.ts +++ b/apps/api/src/observability.ts @@ -1,4 +1,5 @@ import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -9,12 +10,19 @@ import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; import * as HttpEffect from "effect/unstable/http/HttpEffect"; import * as HttpMiddleware from "effect/unstable/http/HttpMiddleware"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpTraceContext from "effect/unstable/http/HttpTraceContext"; import * as Otlp from "effect/unstable/observability/Otlp"; import { Resource } from "sst/resource/cloudflare"; -import { ExecutionContext } from "@leuchtturm/api/execution-context"; - export namespace Observability { + export interface Interface { + readonly flush: Effect.Effect; + } + + export class Service extends Context.Service()( + "@leuchtturm/api/Observability", + ) {} + export const requestDurationBoundaries = Metric.exponentialBoundaries({ start: 0.5, factor: 2, @@ -35,89 +43,103 @@ export namespace Observability { export const middleware = HttpMiddleware.make((app) => Effect.gen(function* () { const startedAt = yield* Clock.currentTimeMillis; + const request = yield* HttpServerRequest.HttpServerRequest; - yield* HttpEffect.appendPreResponseHandler((request, response) => - Effect.gen(function* () { - const durationMs = (yield* Clock.currentTimeMillis) - startedAt; - const attributes = { - method: request.method, - path: Option.getOrElse( - Option.map(HttpServerRequest.toURL(request), (url) => url.pathname), - () => request.url, - ), - status: String(response.status), - }; + return yield* Effect.useSpan( + `${request.method} ${Option.getOrElse( + Option.map(HttpServerRequest.toURL(request), (url) => url.pathname), + () => request.url, + )}`, + { + attributes: Option.match(HttpServerRequest.toURL(request), { + onNone: () => ({ "http.request.method": request.method }), + onSome: (url) => ({ + "http.request.method": request.method, + "url.full": url.toString(), + "url.path": url.pathname, + }), + }), + kind: "server", + parent: Option.getOrUndefined(HttpTraceContext.fromHeaders(request.headers)), + root: Option.isNone(HttpTraceContext.fromHeaders(request.headers)), + }, + (span) => + Effect.gen(function* () { + yield* HttpEffect.appendPreResponseHandler((request, response) => + Effect.gen(function* () { + const durationMs = (yield* Clock.currentTimeMillis) - startedAt; + const metricAttributes = { + method: request.method, + path: Option.getOrElse( + Option.map(HttpServerRequest.toURL(request), (url) => url.pathname), + () => request.url, + ), + status: String(response.status), + }; - yield* Effect.all([ - Metric.update( - Metric.counter("api_requests_total", { - attributes, - description: "Total number of API requests handled by the worker.", - incremental: true, - }), - 1, - ), - Metric.update( - Metric.histogram("api_request_duration_ms", { - attributes, - boundaries: requestDurationBoundaries, - description: "End-to-end duration of API request handling in milliseconds.", + span.attribute("http.response.status_code", response.status); + + yield* Effect.all([ + Metric.update( + Metric.counter("api_requests_total", { + attributes: metricAttributes, + description: "Total number of API requests handled by the worker.", + incremental: true, + }), + 1, + ), + Metric.update( + Metric.histogram("api_request_duration_ms", { + attributes: metricAttributes, + boundaries: requestDurationBoundaries, + description: "End-to-end duration of API request handling in milliseconds.", + }), + durationMs, + ), + ]); + + return response; }), - durationMs, - ), - ]); + ); - return response; - }), + return yield* app.pipe(Effect.withParentSpan(span)); + }), ); - - return yield* app; }), ); export const layer = Layer.mergeAll( Layer.effectContext( Effect.gen(function* () { - const executionContext = yield* ExecutionContext.Service; - const scope = yield* Scope.Scope; const observabilityScope = yield* Scope.make(); - yield* Scope.addFinalizer( - scope, - Effect.sync(() => { - executionContext.waitUntil( - Effect.runPromise(Scope.close(observabilityScope, Exit.void)), - ); - }), - ); - - return yield* Layer.buildWithScope( - Otlp.layerProtobuf({ - baseUrl: Resource.GrafanaOtlpConfig.url, - headers: { Authorization: Resource.GrafanaOtlpConfig.authorization }, - loggerExcludeLogSpans: false, - loggerMergeWithExisting: true, - resource: { - serviceName: "leuchtturm-api", - attributes: { - "service.namespace": "leuchtturm", - app: "leuchtturm", - stage: Resource.App.stage, + return Context.add( + yield* Layer.buildWithScope( + Otlp.layerProtobuf({ + baseUrl: Resource.GrafanaOtlpConfig.url, + headers: { Authorization: Resource.GrafanaOtlpConfig.authorization }, + loggerExcludeLogSpans: false, + loggerMergeWithExisting: true, + resource: { + serviceName: "leuchtturm-api", + attributes: { + "service.namespace": "leuchtturm", + app: "leuchtturm", + stage: Resource.App.stage, + }, }, - }, - shutdownTimeout: "3 seconds", - }).pipe(Layer.provide(FetchHttpClient.layer)), - observabilityScope, + shutdownTimeout: "3 seconds", + }).pipe(Layer.provide(FetchHttpClient.layer)), + observabilityScope, + ), + Service, + Service.of({ + flush: Scope.close(observabilityScope, Exit.void), + }), ); }), ), - Layer.succeed( - HttpMiddleware.SpanNameGenerator, - (request: HttpServerRequest.HttpServerRequest) => - `${request.method} ${Option.getOrElse( - Option.map(HttpServerRequest.toURL(request), (url) => url.pathname), - () => request.url, - )}`, - ), + // The built-in HTTP tracer ends its server span asynchronously, racing our flush. + Layer.succeed(HttpMiddleware.TracerDisabledWhen, () => true), ); } From 36c4e3a4d12ed8e99d8589019b0cad23dbdb213c Mon Sep 17 00:00:00 2001 From: Christoph Schmatzler Date: Fri, 15 May 2026 09:51:49 +0000 Subject: [PATCH 6/7] Tighten header breadcrumb text --- apps/web/src/components/header.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 498159d..f2f853a 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -74,7 +74,7 @@ export function Header({
- + ( <> - Date: Fri, 15 May 2026 11:10:44 +0000 Subject: [PATCH 7/7] Add api PostHog error tracking --- apps/api/src/feature-flags.ts | 38 +++----------- apps/api/src/index.ts | 22 +++++++- apps/api/src/observability.ts | 45 ++++++++++++++++ apps/api/src/posthog.ts | 85 +++++++++++++++++++++++++++++++ apps/api/src/product-analytics.ts | 22 ++------ 5 files changed, 161 insertions(+), 51 deletions(-) diff --git a/apps/api/src/feature-flags.ts b/apps/api/src/feature-flags.ts index 245c746..f504ba1 100644 --- a/apps/api/src/feature-flags.ts +++ b/apps/api/src/feature-flags.ts @@ -1,13 +1,8 @@ -import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { - FeatureFlagError, - FeatureFlagEvaluationError, - FeatureFlagProviderRequestError, -} from "@leuchtturm/api/feature-flags/errors"; +import { FeatureFlagError } from "@leuchtturm/api/feature-flags/errors"; import { Posthog } from "@leuchtturm/api/posthog"; export namespace FeatureFlags { @@ -23,35 +18,14 @@ export namespace FeatureFlags { ) {} export const layer = Layer.effect(Service)( - Effect.sync(() => { - const client = Posthog.create(); + Effect.gen(function* () { + const posthog = yield* Posthog.Service; return Service.of({ isEnabled: (key, userId) => - Effect.tryPromise({ - try: () => - client.getFeatureFlagResult(key, userId, { - sendFeatureFlagEvents: false, - }), - catch: (cause) => cause, - }).pipe( - Effect.tapCause((cause) => - Effect.annotateCurrentSpan({ - "error.original_cause": Cause.pretty(cause), - }), - ), - Effect.mapError( - () => - new FeatureFlagProviderRequestError({ - operation: `Evaluate feature flag ${key} for user ${userId}`, - }), - ), - Effect.filterOrFail( - (result) => result !== undefined, - () => new FeatureFlagEvaluationError({ key, userId }), - ), - Effect.map((result) => result.enabled), - ), + posthog + .getFeatureFlagResult(key, userId, { sendFeatureFlagEvents: false }) + .pipe(Effect.map((result) => result.enabled)), }); }), ); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 9c1f8d4..91bc6ca 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,3 +1,4 @@ +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -18,6 +19,7 @@ import { ZeroHandler } from "@leuchtturm/api/handlers/zero/index"; import { AuthMiddleware } from "@leuchtturm/api/middleware/auth-middleware"; import { RequestContext } from "@leuchtturm/api/middleware/request-context"; import { Observability } from "@leuchtturm/api/observability"; +import { Posthog } from "@leuchtturm/api/posthog"; import { Auth } from "@leuchtturm/core/auth"; import { Database } from "@leuchtturm/core/database"; import { InternalServerError } from "@leuchtturm/core/errors"; @@ -48,6 +50,18 @@ namespace Api { middleware: (app) => app.pipe( HttpMiddleware.logger, + HttpMiddleware.make((app) => + Effect.gen(function* () { + const observability = yield* Observability.Service; + const requestContext = yield* RequestContext.Service; + + return yield* app.pipe( + Effect.tapCause((cause) => + observability.captureUnexpectedCause(cause, requestContext), + ), + ); + }), + ), RequestContext.middleware, HttpMiddleware.cors({ allowedOrigins: (origin) => { @@ -74,7 +88,7 @@ namespace Api { return yield* Effect.gen(function* () { const services = yield* Layer.mergeAll( - Observability.layer, + Observability.layer.pipe(Layer.provide(Posthog.layer)), Auth.defaultLayer, ZeroDatabase.layer, ).pipe(Layer.provideMerge(Database.layer(Resource.Database.connectionString)), Layer.build); @@ -85,6 +99,12 @@ namespace Api { try: () => handler(request, handlerContext), catch: (cause) => cause, }).pipe( + Effect.tapError((error) => + observability.captureUnexpectedCause( + Cause.die(error), + Context.get(context, RequestContext.Service), + ), + ), Effect.mapError(() => new InternalServerError()), Effect.ensuring( Effect.sync(() => { diff --git a/apps/api/src/observability.ts b/apps/api/src/observability.ts index e6c7949..d379221 100644 --- a/apps/api/src/observability.ts +++ b/apps/api/src/observability.ts @@ -1,3 +1,4 @@ +import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -14,8 +15,16 @@ import * as HttpTraceContext from "effect/unstable/http/HttpTraceContext"; import * as Otlp from "effect/unstable/observability/Otlp"; import { Resource } from "sst/resource/cloudflare"; +import { RequestContext } from "@leuchtturm/api/middleware/request-context"; +import { Posthog } from "@leuchtturm/api/posthog"; +import { Session } from "@leuchtturm/api/session"; + export namespace Observability { export interface Interface { + readonly captureUnexpectedCause: ( + cause: Cause.Cause, + request: RequestContext.Interface, + ) => Effect.Effect; readonly flush: Effect.Effect; } @@ -112,6 +121,35 @@ export namespace Observability { Layer.effectContext( Effect.gen(function* () { const observabilityScope = yield* Scope.make(); + const posthog = yield* Posthog.Service; + + function captureUnexpectedDefect(defect: unknown, request: RequestContext.Interface) { + return Effect.gen(function* () { + const currentContext = yield* Effect.context(); + const session = Context.getOption(currentContext, Session.Service); + + const sessionProperties = Option.match(session, { + onNone: () => ({}), + onSome: (session) => ({ + $session_id: session.session.id, + auth_session_id: session.session.id, + user_email: session.user.email, + user_id: session.user.id, + }), + }); + + return yield* posthog.captureException(defect, undefined, { + $process_person_profile: false, + ...sessionProperties, + app: "leuchtturm", + method: request.method, + path: request.path, + request_id: request.requestId, + service: "leuchtturm-api", + stage: Resource.App.stage, + }); + }); + } return Context.add( yield* Layer.buildWithScope( @@ -134,6 +172,13 @@ export namespace Observability { ), Service, Service.of({ + captureUnexpectedCause: (cause, request) => + Effect.all( + cause.reasons + .filter(Cause.isDieReason) + .map((reason) => captureUnexpectedDefect(reason.defect, request)), + { discard: true }, + ), flush: Scope.close(observabilityScope, Exit.void), }), ); diff --git a/apps/api/src/posthog.ts b/apps/api/src/posthog.ts index 00be28e..c26149a 100644 --- a/apps/api/src/posthog.ts +++ b/apps/api/src/posthog.ts @@ -1,11 +1,96 @@ +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type { FeatureFlagResult } from "posthog-node"; import { PostHog as PostHogEdge } from "posthog-node/edge"; import { Resource } from "sst/resource/cloudflare"; +import { ExecutionContext } from "@leuchtturm/api/execution-context"; +import { + FeatureFlagEvaluationError, + FeatureFlagProviderRequestError, +} from "@leuchtturm/api/feature-flags/errors"; + export namespace Posthog { + export interface Interface { + readonly capture: ( + distinctId: string, + event: string, + properties?: Record, + ) => Effect.Effect; + readonly captureException: ( + error: unknown, + distinctId: string | undefined, + properties: Record, + ) => Effect.Effect; + readonly getFeatureFlagResult: ( + key: string, + distinctId: string, + options?: { readonly sendFeatureFlagEvents?: boolean }, + ) => Effect.Effect< + FeatureFlagResult, + FeatureFlagEvaluationError | FeatureFlagProviderRequestError + >; + } + + export class Service extends Context.Service()("@leuchtturm/api/Posthog") {} + export function create(waitUntil?: (promise: Promise) => void) { return new PostHogEdge(Resource.PostHogProjectApiKey.value, { host: Resource.PostHogHost.value, waitUntil, }); } + + export const layer = Layer.effect(Service)( + Effect.gen(function* () { + const executionContext = yield* ExecutionContext.Service; + const client = create(executionContext.waitUntil); + const capture = Effect.fn("Posthog.capture")(function* ( + distinctId: string, + event: string, + properties: Record = {}, + ) { + yield* Effect.sync(() => { + client.capture({ distinctId, event, properties }); + }); + }); + const captureException = Effect.fn("Posthog.captureException")(function* ( + error: unknown, + distinctId: string | undefined, + properties: Record, + ) { + yield* Effect.sync(() => { + client.captureException(error, distinctId, properties); + }); + }); + const getFeatureFlagResult = Effect.fn("Posthog.getFeatureFlagResult")(function* ( + key: string, + distinctId: string, + options?: { readonly sendFeatureFlagEvents?: boolean }, + ) { + return yield* Effect.tryPromise({ + try: () => client.getFeatureFlagResult(key, distinctId, options), + catch: (cause) => cause, + }).pipe( + Effect.tapCause((cause) => + Effect.annotateCurrentSpan({ "error.original_cause": Cause.pretty(cause) }), + ), + Effect.mapError( + () => + new FeatureFlagProviderRequestError({ + operation: `Evaluate feature flag ${key} for user ${distinctId}`, + }), + ), + Effect.filterOrFail( + (result) => result !== undefined, + () => new FeatureFlagEvaluationError({ key, userId: distinctId }), + ), + ); + }); + + return Service.of({ capture, captureException, getFeatureFlagResult }); + }), + ); } diff --git a/apps/api/src/product-analytics.ts b/apps/api/src/product-analytics.ts index cd91d33..4c19fd3 100644 --- a/apps/api/src/product-analytics.ts +++ b/apps/api/src/product-analytics.ts @@ -2,7 +2,6 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { ExecutionContext } from "@leuchtturm/api/execution-context"; import { Posthog } from "@leuchtturm/api/posthog"; export namespace ProductAnalytics { @@ -11,7 +10,7 @@ export namespace ProductAnalytics { distinctId: string, event: string, properties?: Record, - ) => Effect.Effect; + ) => Effect.Effect; } export class Service extends Context.Service()( @@ -19,24 +18,11 @@ export namespace ProductAnalytics { ) {} export const layer = Layer.effect(Service)( - Effect.sync(() => { - const client = Posthog.create(); + Effect.gen(function* () { + const posthog = yield* Posthog.Service; return Service.of({ - capture: (distinctId, event, properties = {}) => - Effect.gen(function* () { - const executionContext = yield* ExecutionContext.Service; - - yield* Effect.sync(() => { - executionContext.waitUntil( - client.captureImmediate({ - distinctId, - event, - properties, - }), - ); - }); - }), + capture: posthog.capture, }); }), );