Two products, self-hosted locally via Docker, sharing one ClickHouse server
(separate databases — langfuse and default) instead of running two:
- Langfuse — LLM/AI tracing (email classification, embeddings). Own database
langfuseon the shared server. - ClickStack (HyperDX) — general app observability via OpenTelemetry. Database
defaulton the same server.
This mirrors the intended end-state: eventually both point at the same ClickHouse
Cloud service instead of a local container. Uses ClickStack's individual-services
compose (clickstack-otel-collector + clickstack-app + clickstack-mongo, app
metadata only), not the hyperdx-all-in-one image — that one bundles its own
ClickHouse with no way to point it at an external server.
Both products are optional at runtime: if their env vars are unset, the app starts
and runs normally with tracing disabled (see packages/env/index.ts).
-
Generate secrets and add them to
.env.local(not committed):echo "LANGFUSE_SALT=$(openssl rand -hex 32)" >> .env.local echo "LANGFUSE_ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env.local echo "LANGFUSE_NEXTAUTH_SECRET=$(openssl rand -hex 32)" >> .env.local
-
Langfuse headless-inits an org/project/user/API-key pair on first boot (KAI-189) — pick the key pair yourself and add everything to
.env.localbefore firstup, so the values match on both sides:echo "LANGFUSE_INIT_PROJECT_PUBLIC_KEY=pk-lf-$(openssl rand -hex 16)" >> .env.local echo "LANGFUSE_INIT_PROJECT_SECRET_KEY=sk-lf-$(openssl rand -hex 16)" >> .env.local echo "LANGFUSE_INIT_USER_PASSWORD=$(openssl rand -hex 12)" >> .env.local echo "HYPERDX_API_KEY=$(openssl rand -hex 16)" >> .env.local
Then copy the
LANGFUSE_INIT_PROJECT_*values intoLANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEYtoo (same key pair, two purposes: one bootstraps the Langfuse container, the other is whatapps/apireads to send traces). -
Start the stack (detached — no dedicated terminal needed, unlike
bun dev/bun run dev:inngest):bun run dev:observability
To stop it:
bun run dev:observability:down. -
Open:
- Langfuse UI: http://localhost:3003 — sign in with
LANGFUSE_INIT_USER_EMAIL(defaultlocal@kairo.dev) /LANGFUSE_INIT_USER_PASSWORD. No manual org/project/API-key creation — headless init already did it. - HyperDX UI: http://localhost:8080 — does need a manual first-run
signup (no headless-init equivalent here) at http://localhost:8080/join.
The "Local ClickHouse" connection and its 4 sources (Logs, Traces, Metrics,
Sessions) are pre-provisioned via
DEFAULT_CONNECTIONS/DEFAULT_SOURCESin the compose file, no manual wiring needed there.OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318already matches the compose file's exposed port.
- Langfuse UI: http://localhost:3003 — sign in with
-
Required — the endpoint var itself. Every
*OTEL_EXPORTER_OTLP_ENDPOINTvar isoptional()in each app'senv.ts— unset means telemetry silently no-ops (by design, so the app works with zero observability config). That also means a missing var doesn't error, it just produces "no traffic", easy to mistake for a real bug. Set it explicitly per app:echo "OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318" >> .env.local # apps/api echo "VITE_OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318" >> .env.local # apps/dashboard echo "NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318" >> .env.local # apps/landing, apps/kelan echo "EXPO_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318" >> .env.local # apps/mobile
-
Required — ingestion auth. ClickStack's OTel collector rejects every export with a silent 401 unless callers send the Ingestion API key as a raw
authorizationheader (noBearerprefix — confirmed against a live instance).HYPERDX_API_KEYin the compose file does not set this key — HyperDX generates its own in Mongo, independent of that env var. After the first HyperDX signup, go to Team Settings → API & Agents → "Ingestion API key" → reveal it, then set it on every app that emits traces:echo "OTEL_EXPORTER_OTLP_HEADERS=authorization=<ingestion-key>" >> .env.local # apps/api echo "VITE_HYPERDX_INGESTION_API_KEY=<ingestion-key>" >> .env.local # apps/dashboard echo "NEXT_PUBLIC_HYPERDX_INGESTION_API_KEY=<ingestion-key>" >> .env.local # apps/landing, apps/kelan echo "EXPO_PUBLIC_HYPERDX_INGESTION_API_KEY=<ingestion-key>" >> .env.local # apps/mobile
Without this, dashboards look like "no traffic yet" instead of "broken" — the OTel SDK swallows exporter errors by default (set
OTEL_LOG_LEVEL=debugto see the real401 Unauthorizedif traces still don't show up). -
Restart every app's dev server (
bun dev) so the new env vars are picked up — Vite/Next bakeVITE_*/NEXT_PUBLIC_*vars in at dev-server start, a page reload alone is not enough.
Trigger a real classification (e.g. run the app's Gmail sync/pipeline, or
bun run eval:pipeline from repo root) and check:
- Langfuse UI → Traces: a
email-classificationgeneration with prompt/response, model, and token usage. - HyperDX UI → Search: spans for the
apps/apiHTTP/fetch calls (service namekairo-api, fromOTEL_SERVICE_NAME).
Both products ship an MCP server; register them once real API keys exist:
claude mcp add --transport http langfuse http://localhost:3003/api/public/mcp \
--header "Authorization: Basic $(echo -n "$LANGFUSE_PUBLIC_KEY:$LANGFUSE_SECRET_KEY" | base64)"
claude mcp add --transport http clickstack http://localhost:8080/api/mcp \
--header "Authorization: Bearer <HyperDX Personal API Access Key, from Team Settings>"Both products expose their dashboards as code via REST API — provisioned, not manually clicked together:
bun run observability:provisionRuns both:
scripts/observability/langfuse-dashboards.ts— readsLANGFUSE_BASE_URL/LANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEY. Creates "Kairo — LLM Observability": generation count/p95 latency/cost/error-level, all grouped by generation name (email-classification,embedding,embedding-batch).scripts/observability/hyperdx-dashboard.ts— readsHYPERDX_API_URL(http://localhost:8000, the API port — not the UI's:8080) andHYPERDX_PERSONAL_API_KEY. Not theHYPERDX_API_KEYenv var from the compose file — that one only authenticates telemetry ingestion. Get the Personal API access key from Team Settings → API & Agents, after the manual HyperDX signup (step 4 above — HyperDX has no headless-init env vars). Creates "Kairo — App Observability": request count + p95 duration + error count (StatusCode:Error, Lucene). Per-metric filters go directly on theselect[]item aswhere+whereLanguage— anaggConditionfield and a top-levelconfig.whereboth looked plausible from the schema but are silently accepted and dropped, no validation error. Confirmed against a live instance, not guessed.
Neither script is idempotent — re-running creates duplicates. Meant to run once against a fresh instance.
Remaining gap: alerts (both products can alert on dashboard tiles) aren't provisioned yet — they need a notification channel (Slack/email) configured first, which local dev doesn't have.
The public semantic service emits embeddings.operation spans through the existing
OpenTelemetry exporter. Filter by kairo.embeddings.operation, source, provider,
model, dimensions, outcome and reason. generation distinguishes reused,
attempted and none; actual generation still uses the existing Langfuse trace.
The span's trace/span IDs provide correlation. Durable attempts also carry the
engine-generated request ID as a trace attribute, never as a metric label.
service.inspectCoverage({ sources, basis: 'authorized_references' }) inspects
explicit references through the registered adapters. Use known_requests when
the selection comes from accepted indexing requests. Inspection never generates
vectors or schedules processing. It returns counts by source and configured vector
space: ready, missing, stale, incompatible and unknown. Duplicate references
count once. Failed authorization, disabled reads or unavailable storage remain
unknown. An omitted inventory has a null denominator; an empty selection or any
unknown entries has a null coverage ratio.
stale means the source revision or preparation changed. incompatible means the
stored provider, model, dimensions or vector cannot be used in the configured space.
Indexing emits coverage for its known source; reads, grouping and explicit
inspection emit embeddings.coverage snapshots. These describe the inspected
selection, not the account's or platform's complete inventory. Repeated snapshots
can overlap: do not sum their denominators into a unique-source population.
Run these read-only queries against the existing ClickStack default.otel_traces
table. Both use the last hour; change the timestamp predicates for another window.
The first counts public operation calls, excluding durable transport/attempt spans
to avoid counting indexing twice. source contains registered source kinds;
multi-source operations list their kinds together.
WITH
SpanAttributes['kairo.embeddings.operation'] AS operation,
SpanAttributes['kairo.embeddings.source'] AS source,
SpanAttributes['kairo.embeddings.provider'] AS provider,
SpanAttributes['kairo.embeddings.model'] AS model,
SpanAttributes['kairo.embeddings.dimensions'] AS dimensions,
SpanAttributes['kairo.embeddings.outcome'] AS outcome,
SpanAttributes['kairo.embeddings.reason'] AS reason
SELECT
operation, source, provider, model, dimensions,
count() AS operations,
countIf(outcome = 'disabled') AS disabled,
countIf(outcome IN ('failed', 'unavailable', 'partial')) AS degraded,
countIf(reason IN ('provider_unavailable', 'provider_rejected')) AS provider_failures,
countIf(reason = 'storage_unavailable') AS storage_failures,
countIf(operation = 'retrieve' AND outcome = 'ok'
AND SpanAttributes['kairo.embeddings.matches'] = '0') AS successful_empty,
countIf(SpanAttributes['kairo.embeddings.generation'] = 'reused') AS reused,
countIf(SpanAttributes['kairo.embeddings.generation'] = 'attempted') AS generation_attempts,
countIf(outcome != 'disabled') AS enabled_operations,
degraded / nullIf(enabled_operations, 0) AS degraded_ratio
FROM default.otel_traces
WHERE Timestamp >= now() - INTERVAL 1 HOUR AND Timestamp < now()
AND SpanName = 'embeddings.operation'
AND operation IN ('index', 'retrieve', 'read_indexes', 'group')
GROUP BY operation, source, provider, model, dimensions
ORDER BY operations DESC;Coverage is shown per observation, with its scope and denominator. A null ratio means unknown or no measurable population, not zero or complete coverage.
WITH
toUInt64OrNull(SpanAttributes['kairo.embeddings.coverage.denominator']) AS denominator,
toUInt64OrNull(SpanAttributes['kairo.embeddings.coverage.ready']) AS ready,
toUInt64OrNull(SpanAttributes['kairo.embeddings.coverage.unknown']) AS unknown
SELECT
Timestamp, TraceId,
SpanAttributes['kairo.embeddings.source'] AS source,
SpanAttributes['kairo.embeddings.provider'] AS provider,
SpanAttributes['kairo.embeddings.model'] AS model,
SpanAttributes['kairo.embeddings.dimensions'] AS dimensions,
SpanAttributes['kairo.embeddings.coverage.basis'] AS basis,
denominator, ready,
toUInt64OrNull(SpanAttributes['kairo.embeddings.coverage.missing']) AS missing,
toUInt64OrNull(SpanAttributes['kairo.embeddings.coverage.stale']) AS stale,
toUInt64OrNull(SpanAttributes['kairo.embeddings.coverage.incompatible']) AS incompatible,
unknown,
if(denominator > 0 AND unknown = 0, ready / denominator, NULL) AS coverage_ratio
FROM default.otel_traces
WHERE Timestamp >= now() - INTERVAL 1 HOUR AND Timestamp < now()
AND SpanName = 'embeddings.coverage'
ORDER BY Timestamp DESC
LIMIT 100;To inspect durable execution, filter operation spans by submit or index_job.
Acceptance, paused work, execution failures and indexing results are separate
outcomes. Failure rates count attempts, not unique requests. No source text,
vectors, business identifiers, provider response bodies or credentials are emitted.
One shared package, three runtime-specific entry points — not a single universal SDK (impossible: OpenTelemetry itself splits Node vs browser vs React Native SDKs), and not one package per runtime either (that would scatter the same config surface across the repo). A single package, split by subpath export:
@kairo/observability/node—initNodeTelemetry(), a thin wrapper aroundNodeSDK+LangfuseSpanProcessor+OTLPTraceExporter. Used byapps/api'ssrc/instrumentation.ts.@kairo/observability/web—initWebTelemetry(),WebTracerProvider+FetchInstrumentation+OTLPTraceExporter. Used byapps/dashboard'ssrc/main.tsx, before the app renders. NoZoneContextManager(it requires transpiling to ES2015, which conflicts with this monorepo's ES2022 target) — spans are correct per-fetch-call; deeply nested async parent/child linking across awaits isn't guaranteed. Revisit if that becomes a real need.@kairo/observability/mobile—initMobileTelemetry(), a bareBasicTracerProvider+OTLPTraceExporter, no auto-instrumentation (Hermes doesn't support the DOM/Node APIs the other two entry points patch). Used byapps/mobile'sapp/_layout.tsx. Manual spans only — see "What's instrumented today" below.
landing and kelan (both Next.js) needed the same server-side pattern
(@vercel/otel), so that's now apps/{landing,kelan}/instrumentation.ts —
still using @kairo/observability/web for their browser side, same as
dashboard. packages/observability didn't need a new export for this: Next.js
server-side tracing is @vercel/otel itself (a thin wrapper apps call
directly), not something @kairo/observability needs to own.
Every app in the monorepo, including apps/mobile:
| App | Instrumentation | Verified with real data in ClickHouse? |
|---|---|---|
packages/intelligence (Langfuse) |
startObservation spans in classify.ts/embed.ts |
Yes — Langfuse dashboard shows real generations |
apps/api |
@hono/otel middleware |
Yes — GET /api/v1/health (200) and GET /api/v1/sidebar/counts (401) both landed with correct http.route/status |
apps/landing |
@vercel/otel (server) + @kairo/observability/web (browser) |
Yes — server spans + a real browser fetch('/') span, url.full matched |
apps/kelan |
same as landing |
Yes — 500+ spans from a real authenticated session (server + browser) |
apps/dashboard |
@kairo/observability/web in main.tsx |
Yes — real browser session (ticket detail view, Supabase REST calls) landed 58 spans with correct url.full/status |
apps/mobile |
@kairo/observability/mobile, manual spans only |
No — app still can't boot (no app.json/dev script, pre-existing gap) |
apps/api note: @opentelemetry/auto-instrumentations-node (bundled in
@kairo/observability/node) patches Node's http/undici modules — Bun's
native server and fetch never go through either, so it silently created
zero spans regardless of the ingestion-auth fix. Fixed by adding
@hono/otel's httpInstrumentationMiddleware in src/index.ts, which
instruments at the Hono middleware level instead — works on any runtime
(Bun, Node, edge). initNodeTelemetry() is still required alongside it: it's
what registers the tracer provider/exporter the middleware attaches spans to,
and it's still how Langfuse's LangfuseSpanProcessor gets registered.
apps/dashboard note: not a code bug — a missing env var. Its browser-side
initWebTelemetry() call is a no-op whenever VITE_OTEL_EXPORTER_OTLP_ENDPOINT
is unset (it's optional() by design in env.ts), and a local .env.local
had VITE_HYPERDX_INGESTION_API_KEY set but not this one — no error, just
silent "no traffic". Fixed by adding the missing var and restarting the Vite
dev server (see step 5 above).
apps/landing/apps/kelan note: the browser side is
@kairo/observability/web rendered as <InstrumentationClient /> in
app/layout.tsx — must be rendered, not just imported: a side-effect-only
import './_instrumentation-client' from the Server Component layout isn't
guaranteed to survive Next.js's client bundling, and was silently dropped
until it became an actually-rendered component (returns null).
apps/mobile note: @kairo/observability/mobile (app/_layout.tsx), a bare
BasicTracerProvider + OTLPTraceExporter, no auto-instrumentation. Real RN
OTel distros (Splunk's, Honeycomb's) add auto-instrumentation via a native
module that requires an EAS development build (breaks Expo Go) — this
is the manual-spans-only alternative that doesn't need either:
trace.getTracer('kairo-mobile') is ready to call from anywhere the moment
the app has a real event worth tracing. It doesn't have one yet —
apps/mobile/app/index.tsx is still a single static screen — so
initMobileTelemetry() runs at startup but nothing calls startSpan()
anywhere yet. The OTLP exporter's fetch-based transport is expected to bundle
under Metro (the same package works under Vite in web.ts) but that's
untested. If Metro can't resolve it, swap the exporter in
packages/observability/src/mobile.ts for a plain fetch() POST of OTLP
JSON — nothing else in the file would need to change.
Deferred, unrelated to the instrumentation work itself:
- Production deployment (Phase 2 of KAI-126) — this doc covers local only.
apps/api'sstart/build:apiscripts don't preloadinstrumentation.tsyet; wire that up when prod infra for both products actually exists.
9 containers total (clickhouse, 5 Langfuse services, clickstack-otel-collector,
clickstack-app, clickstack-mongo). More containers than the all-in-one image, but
the heaviest one (ClickHouse) is deduplicated instead of running twice. Budget at
least 2GB of RAM for Docker Desktop — under ~1GB, ClickHouse/MinIO start failing
health checks under memory pressure (observed directly while building this).