Skip to content

Observability Audit & Improvement Plan

This document provides a comprehensive audit of the observability implementation in the ERP-Unlocked monorepo, identifies gaps, and proposes a unified tracing solution to enable end-to-end request tracking across all services.

  • 3 services have full observability coverage (webapp, pdf-api, pdf-worker)
  • 3 services have partial observability coverage (dagster, trigger, email-api)
  • 4 observability platforms are in use (Better Stack, Langfuse, OpenTelemetry, Logtail)
  • Critical gap: Traces are fragmented across services, making end-to-end debugging difficult

Implement a Correlation ID + Unified OTEL approach that enables:

  • Single trace from user action → final result
  • Cross-service trace propagation (including Trigger.dev and Dagster)
  • Unified querying in Better Stack by correlation_id
  1. Observability Tools Overview
  2. Tool Responsibilities
  3. Service-by-Service Analysis
  4. Current Trace Flow
  5. Implementation Gaps
  1. Unified Tracing Architecture
  2. Implementation Plan
  3. Code Examples
  1. Reference
flowchart TB
subgraph Platforms["☁️ Observability Platforms"]
BS["Better Stack<br/>───────────<br/>Logs, Traces, Metrics"]
LF["Langfuse<br/>───────────<br/>LLM Observability"]
end
subgraph Standards["📐 Standards & Protocols"]
OTEL["OpenTelemetry<br/>───────────<br/>OTLP Protocol"]
W3C["W3C Trace Context<br/>───────────<br/>traceparent header"]
end
subgraph NodeLibs["📦 Node.js Libraries"]
OtelNode["@opentelemetry/sdk-node"]
Pino["Pino Logger"]
RepoObs["@repo/observability"]
end
subgraph PyLibs["🐍 Python Libraries"]
OtelPy["opentelemetry-python"]
Structlog["structlog"]
Logtail["logtail-python"]
end
RepoObs --> OtelNode
RepoObs --> Pino
OtelNode --> OTEL
Pino --> OTEL
OtelPy --> OTEL
Structlog --> OTEL
Logtail --> BS
OTEL --> BS
OTEL --> W3C
LF -.->|"Isolated tracer"| OTEL
style Platforms fill:#60a5fa,color:#000
style Standards fill:#a78bfa,color:#000
style NodeLibs fill:#4ade80,color:#000
style PyLibs fill:#fbbf24,color:#000
ToolTypePrimary UseServices
OpenTelemetrySDK/ProtocolDistributed tracing, metricswebapp, pdf-api, pdf-worker
Better StackPlatformLog aggregation, tracesAll instrumented services
LangfusePlatformLLM observabilitypdf-worker
PinoLibrary (Node.js)Structured loggingwebapp, trigger, email-api
structlogLibrary (Python)Structured loggingpdf-api, pdf-worker
PlatformEndpointProtocol
Better Stack (OTLP)https://in-otel.betterstack.comHTTP/Protobuf
Better Stack (Logs)https://in.logs.betterstack.comLogtail SDK
Langfusehttps://cloud.langfuse.comREST API

Package: packages/observability/

ResponsibilityImplementation
Distributed TracingAuto span creation, W3C traceparent propagation
Metrics CollectionHTTP counters, DB duration, sync health scores
Log Correlationtrace_id/span_id injection via Pino logHook

Key Files:

  • packages/observability/src/otel-config.ts - NodeSDK initialization
  • packages/observability/src/trace.ts - withTrace, setTraceAttribute
  • apps/webapp/otel-init.cjs - Webapp bootstrap
ResponsibilityFeatures
Log ManagementCentralized aggregation, full-text search
Trace VisualizationDistributed trace viewing, latency analysis
AlertingError rate, latency threshold alerts

Package: packages/pdf-shared/pdf_shared/utils/llm_observability.py

ResponsibilityFeatures
LLM TrackingGemini API monitoring, duration tracking
Token UsageInput/output counting, aggregation
Cost Calculation$0.075/1M input, $0.30/1M output tokens
Quality ScoringJSON parsing, schema validation, extraction success
flowchart LR
subgraph Full["✅ FULL COVERAGE"]
W["webapp"]
PA["pdf-api"]
PW["pdf-worker"]
end
subgraph Partial["⚠️ PARTIAL COVERAGE"]
D["dagster"]
T["trigger"]
E["email-api"]
end
style Full fill:#4ade80,color:#000
style Partial fill:#fbbf24,color:#000
ServiceTracingMetricsLoggingLLM ObsStatus
webappN/AFull OTEL via otel-init.cjs
pdf-apiN/APython OTEL auto-instrumentation
pdf-workerFull OTEL + Langfuse
dagster⚠️⚠️N/AService name only, no SDK
triggerN/ANative logger, no OTEL
email-apiN/APino only, CF Worker limits
sequenceDiagram
autonumber
participant User as 👤 User
participant Webapp as 🌐 Webapp
participant BS as ☁️ Better Stack
participant PdfApi as ⚡ PDF-API
participant PdfWorker as ⚙️ PDF-Worker
participant LF as 🤖 Langfuse
User->>Webapp: Upload PDF
Note over Webapp: trace_id: abc123
Webapp->>BS: Span + Logs
Webapp->>PdfApi: HTTP + traceparent
Note over PdfApi: trace_id: abc123 (continued)
PdfApi->>BS: Span + Logs
PdfApi->>PdfWorker: Celery + context
Note over PdfWorker: trace_id: abc123 (continued)
PdfWorker->>LF: LLM spans
PdfWorker->>BS: Span + Logs

4.2 What’s Broken: Trigger.dev & Dagster

Section titled “4.2 What’s Broken: Trigger.dev & Dagster”
sequenceDiagram
autonumber
participant User as 👤 User
participant Webapp as 🌐 Webapp
participant Trigger as ⏰ Trigger.dev
participant Dagster as 📊 Dagster
participant BS as ☁️ Better Stack
User->>Webapp: Start ERP Sync
Note over Webapp: trace_id: abc123
Webapp->>BS: Span
Webapp->>Trigger: Trigger task (NO trace context!)
Note over Trigger: ❌ NEW trace_id: xyz789
Trigger->>BS: Separate trace
Trigger->>Dagster: Call Dagster
Note over Dagster: ❌ NO tracing at all

Problem: Cannot follow a single request across Trigger.dev and Dagster jobs.

flowchart LR
subgraph Critical["🔴 CRITICAL"]
G1["Fragmented Traces<br/>Cannot follow requests E2E"]
G2["Dagster Invisible<br/>No OTEL SDK"]
end
subgraph Moderate["🟡 MODERATE"]
G3["Trigger.dev Isolated<br/>No trace propagation"]
G4["Pino Correlation<br/>trace_id may be missing"]
end
style Critical fill:#f87171,color:#000
style Moderate fill:#fbbf24,color:#000
GapImpactCurrent State
Fragmented TracesCannot debug cross-service issuesEach service starts new trace
Dagster No OTELETL jobs invisibleOnly service name configured
Trigger.dev IsolatedBackground jobs disconnectedNative logger only
Langfuse SeparateLLM traces not linkedIsolated tracer provider
flowchart TB
subgraph Current["❌ CURRENT - Fragmented"]
direction LR
C1[Webapp Trace 1] --> C2[PDF-API Trace 1]
C3[Trigger Trace 2] --> C4[Dagster No Trace]
end
subgraph Proposed["✅ PROPOSED - Unified"]
direction LR
P1[Webapp] --> P2[PDF-API]
P1 --> P3[Trigger]
P3 --> P4[Dagster]
P5[correlation_id: abc123]
end
style Current fill:#fee2e2
style Proposed fill:#dcfce7
sequenceDiagram
autonumber
participant U as User
participant W as Webapp
participant T as Trigger.dev
participant D as Dagster
participant PA as PDF-API
participant PW as PDF-Worker
participant BS as Better Stack
U->>W: Request
Note over W: Generate correlation_id: abc123
W->>BS: Span (correlation_id: abc123)
W->>T: Task + _traceContext {correlation_id, traceparent}
Note over T: Restore parent trace
T->>BS: Child span (correlation_id: abc123)
T->>D: Job + correlation_id
Note over D: New OTEL span with correlation_id
D->>BS: Span (correlation_id: abc123)
W->>PA: HTTP + traceparent + x-correlation-id
PA->>PW: Celery + context
PW->>BS: All spans (correlation_id: abc123)
Note over BS: Query by correlation_id = abc123<br/>See ALL spans across ALL services

A correlation_id is a UUID generated at the entry point (webapp) that is:

  • Passed in HTTP headers (x-correlation-id)
  • Included in Trigger.dev task payloads
  • Set as span attribute in all services
  • Used as Langfuse session_id
  • Queryable in Better Stack
PhaseTaskEffortPriority
1Correlation ID infrastructure2-3 hrs🔴 High
2Webapp integration1-2 hrs🔴 High
3Trigger.dev integration3-4 hrs🔴 High
4Dagster OTEL2-3 hrs🔴 High
5Langfuse correlation1 hr🟡 Medium
6Better Stack queries30 min🟢 Low

Total: 10-14 hours

If full implementation is too much, start with correlation ID only:

  1. ✅ Generate correlation_id in webapp middleware
  2. ✅ Pass in HTTP headers and Trigger payloads
  3. ✅ Log in all services
  4. ✅ Query Better Stack by correlation_id

Result: 80% benefit with 30% effort.

New Files:

packages/observability/src/correlation.ts
apps/trigger/src/utils/tracing.ts
apps/dagster/erp_pipeline/observability.py

Modified Files:

apps/webapp/src/middleware.ts
apps/webapp/src/lib/observability/fetchWithTrace.ts
apps/trigger/src/tasks/**/*.ts
packages/pdf-shared/pdf_shared/utils/llm_observability.py
packages/observability/src/correlation.ts
import { randomUUID } from 'crypto';
import { context, propagation } from '@opentelemetry/api';
export const CORRELATION_ID_HEADER = 'x-correlation-id';
export const CORRELATION_ID_BAGGAGE_KEY = 'correlation.id';
export function getOrCreateCorrelationId(existingId?: string): string {
return existingId || randomUUID();
}
export function extractCorrelationId(headers: Headers): string | undefined {
return headers.get(CORRELATION_ID_HEADER) || undefined;
}
export function withCorrelationId<T>(correlationId: string, fn: () => T): T {
const baggage = propagation.createBaggage({
[CORRELATION_ID_BAGGAGE_KEY]: { value: correlationId },
});
const ctx = propagation.setBaggage(context.active(), baggage);
return context.with(ctx, fn);
}
apps/webapp/src/middleware.ts
import {
getOrCreateCorrelationId,
extractCorrelationId,
withCorrelationId,
setCorrelationIdAttribute,
CORRELATION_ID_HEADER,
} from '@repo/observability';
import { setTraceAttribute } from '@repo/observability';
export const onRequest = defineMiddleware(async (context, next) => {
const incomingId = extractCorrelationId(context.request.headers);
const correlationId = getOrCreateCorrelationId(incomingId);
// Set as trace attribute
setTraceAttribute('correlation.id', correlationId);
// Store for use in routes
context.locals.correlationId = correlationId;
const response = await next();
response.headers.set('x-correlation-id', correlationId);
return response;
});

8.3 Trigger.dev Task with Trace Restoration

Section titled “8.3 Trigger.dev Task with Trace Restoration”
apps/trigger/src/utils/tracing.ts
import { trace, context, propagation, SpanKind } from '@opentelemetry/api';
const tracer = trace.getTracer('trigger-tasks');
export interface TraceablePayload {
_traceContext?: {
correlationId: string;
traceparent?: string;
};
}
export async function withRestoredTrace<T>(
taskName: string,
payload: TraceablePayload,
fn: () => Promise<T>
): Promise<T> {
const traceContext = payload._traceContext;
if (!traceContext?.traceparent) {
return tracer.startActiveSpan(taskName, fn);
}
// Restore parent context
const parentContext = propagation.extract(context.active(), {
traceparent: traceContext.traceparent,
});
return context.with(parentContext, () => {
return tracer.startActiveSpan(taskName, { kind: SpanKind.CONSUMER }, async span => {
span.setAttribute('correlation.id', traceContext.correlationId);
try {
return await fn();
} finally {
span.end();
}
});
});
}
apps/dagster/erp_pipeline/observability.py
import os
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from functools import wraps
def setup_observability():
resource = Resource.create({
"service.name": "dagster",
"service.version": "1.0.0",
})
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)
return trace.get_tracer(__name__)
tracer = setup_observability()
def traced_asset(name: str):
"""Decorator for tracing Dagster assets"""
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
correlation_id = kwargs.get('correlation_id')
with tracer.start_as_current_span(name) as span:
if correlation_id:
span.set_attribute("correlation.id", correlation_id)
return fn(*args, **kwargs)
return wrapper
return decorator
apps/webapp/src/services/trigger-service.ts
import { tasks } from '@trigger.dev/sdk/v3';
import { context, propagation } from '@opentelemetry/api';
export async function triggerTaskWithTrace(
taskId: string,
payload: Record<string, unknown>,
correlationId: string
) {
// Capture current trace context
const carrier: Record<string, string> = {};
propagation.inject(context.active(), carrier);
return tasks.trigger(taskId, {
...payload,
_traceContext: {
correlationId,
traceparent: carrier.traceparent,
},
});
}
CategoryMetricType
HTTPhttpRequestsTotalCounter
HTTPhttpRequestDurationHistogram
DatabasedbQueryDurationHistogram
ERPerpRequestsTotalCounter
LLMllm_tokens_totalCounter
LLMllm_request_cost_usdCounter
  • docs/observability/README.md - Overview
  • docs/observability/better-stack-integration-guide.md - Better Stack setup
  • packages/observability/README.md - Package docs
  • learnings/DEBUGGING_LEARNINGS.md - Past debugging