Observability stack
withDevtools for dev + withTelemetry for OTel + a custom CruxPlugin for app-specific metrics.
This recipe layers Crux's three observability mechanisms into one stack: devtools for development, OpenTelemetry spans for production, and a custom CruxPlugin to push app-specific metrics (cost per tenant, latency by region) to your monitoring backend.
Primitives used
withDevtools()from@crux/core/observabilitywithTelemetry()from@crux/otel- A custom
CruxPluginimplementinginstall(runtime)with hooks config({ plugins: [...] })to compose them
When to reach for this pattern
- You're going to production and need real observability, not just dev devtools
- You already have an OTel-compatible APM (Datadog, Honeycomb, Grafana, New Relic)
- You want app-specific metrics that aren't standard OTel attributes — tenant ID, feature flag bucket, cost per request
Full code
lib/ai/plugins/cost-tracker.ts
import type { CruxPlugin, CruxRuntime } from '@crux/core'
interface CostTrackerOptions {
onCost: (event: {
promptId: string
modelId: string
tenantId: string | undefined
inputTokens: number
outputTokens: number
estimatedCost: number
durationMs: number
}) => void | Promise<void>
}
export function withCostTracker(options: CostTrackerOptions): CruxPlugin {
return {
name: 'app:cost-tracker',
install(runtime: Readonly<CruxRuntime>) {
return {
instrumentationHooks: {
...runtime.instrumentationHooks,
onGenerateEnd: async (event) => {
// Forward to existing handlers (fan-out)
await runtime.instrumentationHooks?.onGenerateEnd?.(event)
// Push cost metric
await options.onCost({
promptId: event.promptId,
modelId: event.modelId,
tenantId: event.metadata?.tenantId as string | undefined,
inputTokens: event.usage?.inputTokens ?? 0,
outputTokens: event.usage?.outputTokens ?? 0,
estimatedCost: event.usage?.totalCost ?? 0,
durationMs: event.durationMs,
})
},
},
}
},
}
}crux.config.ts
import { config } from '@crux/core'
import { withTelemetry } from '@crux/otel'
import { withCostTracker } from './lib/ai/plugins/cost-tracker'
export default config({
devtools: { serverUrl: process.env.DEVTOOLS_URL }, // local server or tunnel only
plugins: [
// OTel spans for production
withTelemetry({
serviceName: 'my-app',
serviceVersion: process.env.RELEASE_SHA,
attributes: { 'deployment.environment': process.env.NODE_ENV },
}),
// Custom cost metric to your monitoring backend
withCostTracker({
onCost: async (event) => {
await datadogClient.gauge('llm.cost', event.estimatedCost, {
tags: [`prompt:${event.promptId}`, `model:${event.modelId}`, `tenant:${event.tenantId ?? 'unknown'}`],
})
await datadogClient.histogram('llm.duration_ms', event.durationMs, {
tags: [`prompt:${event.promptId}`],
})
},
}),
],
})Pass tenant context per request
import { withSession, createSessionId } from '@crux/core'
import { generate } from '@crux/ai'
import { openai } from '@ai-sdk/openai'
import { summarize } from './prompts'
export async function summarizeForTenant(tenantId: string, text: string) {
return withSession(createSessionId(), async () => {
return generate(summarize, {
model: openai('gpt-4o'),
input: { text },
metadata: { tenantId }, // appears in all instrumentation events
})
})
}How it works
withDevtools()+withTelemetry()+ custom plugin compose. Crux's plugin system fans out instrumentation hooks — everyonGenerateEndfires for all installed plugins. None of them block each other.- Devtools is explicit local/tunnel visibility. When
devtools.serverUrlis set, Crux installs the devtools plugin first. Leave it unset outside deliberate local or staging tunnel sessions. withTelemetryemits OTel spans. Every generate, memory op, compaction, judge, flow step, and tool call becomes a span withcrux.*attributes. Your existing OTel collector ingests them.- Your custom plugin reads from the same hooks. No need to instrument prompts manually —
onGenerateEndfires on every call withusage,durationMs, andmetadatapopulated. metadataflows from call site to plugin. Passmetadata: { tenantId, region, featureFlag }togenerate()and your plugin sees it in every event.
Variations
Devtools via tunnel
For staging environments or controlled debugging sessions, run crux dev --tunnel on your local machine and point DEVTOOLS_URL at the public tunnel. Treat this as an explicit temporary visibility channel, not default production telemetry.
Sampling
If you have high volume and don't want to push every event:
withCostTracker({
onCost: async (event) => {
if (Math.random() > 0.05) return // 5% sample
await datadogClient.gauge(/* ... */)
},
})Trace context propagation across services
Use @crux/convex/server boundaries and ctx.crux.runAction() so spans across Convex actions stay correlated through the hidden __crux context envelope.