SMBs integrating Grok into their workflows lack visibility into how often the model is called, what it costs, and when it fails. Without monitoring, they risk overspending and missing performance degradations.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
This tutorial walks you through building a single-pane observability dashboard for xAI Grok API calls in a Next.js application. You’ll create an InstrumentedGrok client that wraps every Grok API call with OpenTelemetry spans (via @reaatech/otel-genai-semconv-*), attaches real-time cost tracking (via @reaatech/llm-cost-telemetry), and exports traces and metrics to Langfuse for centralized dashboards. By the end, you’ll have three API routes (POST /api/chat, GET /api/metrics, POST /api/reset-metrics) and a dashboard page showing live cost, latency, and error data.
This tutorial is for TypeScript developers who integrate Grok (or any OpenAI-compatible provider) into their applications and want production-grade observability without building it from scratch.
A Langfuse account (cloud or self-hosted) for trace ingestion — sign up at https://langfuse.com
Basic familiarity with Next.js, TypeScript, and API routes
Step 1: Create the project and install dependencies
Start a new Next.js project using the App Router, then install all the packages you’ll need.
terminal
npx
create-next-app@latest
xai-grok-observability
--typescript
--app
--src-dir
--no-tailwind
--eslint
cd xai-grok-observability
Next, install the REAA observability packages, the OpenAI SDK (for Grok’s OpenAI-compatible endpoint), the OpenTelemetry SDK, the Langfuse client, and Pino:
Expected output:pnpm install exits 0 and the packages appear in node_modules/.
Step 2: Configure environment variables
Create a .env.example file at the project root with placeholder values for every configuration variable the app needs:
env
# Env vars used by xai-grok-observability-for-smb-ai-workflow-monitoring.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=development# xAI Grok APIXAI_API_KEY=<your-xai-api-key>XAI_BASE_URL=https://api.x.ai/v1# Langfuse (trace exporter + SDK)LANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>LANGFUSE_HOST=https://cloud.langfuse.com# OpenTelemetry (exported to Langfuse via OTLP)OTEL_SERVICE_NAME=xai-grok-observabilityOTEL_EXPORTER_OTLP_ENDPOINT=<your-langfuse-otlp-endpoint># LoggingLOG_LEVEL=info
Copy this to .env.local and fill in real values:
terminal
cp .env.example .env.local
Expected output:.env.local contains your xAI API key and Langfuse credentials.
Step 3: Create the request normalizer
The normalizeRequest function bridges OpenAI’s snake_case chat completion parameters (max_tokens) to the camelCase LLMRequest shape expected by @reaatech/otel-genai-semconv-core. Create src/lib/request-normalizer.ts:
Expected output: The function maps max_tokens to maxTokens and defaults to 1024 when omitted. Temperature passes through as undefined when not provided.
Step 4: Build the Grok cost tracker
The cost tracker defines per-model pricing data for Grok and exports helpers that create cost telemetry spans. Create src/lib/cost-tracker.ts:
Expected output:getGrokPricing("grok-3") returns { inputPricePerMillion: 3.00, outputPricePerMillion: 12.00 }. estimateGrokCost uses the calculateCostFromTokens utility to compute costs from token counts and per-million prices.
Step 5: Wire up the Langfuse OTLP exporter
The Langfuse exporter initializes the OpenTelemetry Node SDK with an OTLP protobuf trace exporter configured from environment variables. Create src/lib/langfuse-exporter.ts:
ts
import { NodeSDK } from "@opentelemetry/sdk-node";import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";import { resourceFromAttributes } from "@opentelemetry/resources";import pino from "pino";const logger = pino({ level: process.env.LOG_LEVEL || "info" });let sdk: NodeSDK | undefined;let initialized = false;export function initObservability(): void { if (initialized) { logger.warn("Observability already initialized, skipping"); return; } try { const resource = resourceFromAttributes({ "service.name": process.env.OTEL_SERVICE_NAME ?? "xai-grok-observability", }); const traceExporter = new OTLPTraceExporter(); sdk = new NodeSDK({ traceExporter, resource }); sdk.start(); initialized = true; logger.info("Observability initialized"); } catch (err) { logger.warn(err, "Failed to initialize observability"); initialized = false; }}export async function shutdown(): Promise<void> { if (!sdk) { logger.warn("SDK was never started, shutdown skipped"); return; } try { await sdk.shutdown(); initialized = false; logger.info("Observability shut down"); } catch (err) { logger.warn(err, "Error shutting down observability"); }}export function isInitialized(): boolean { return initialized;}
Expected output: When OTEL_EXPORTER_OTLP_ENDPOINT is set, initObservability() starts the SDK and logs “Observability initialized”. Calling it twice is idempotent — the second call logs a warning and returns immediately.
Step 6: Enable the instrumentation hook in next.config.ts
Next.js’s instrumentation.ts hook only fires if you explicitly enable it. Open next.config.ts and add the experimental.instrumentationHook flag:
ts
import type { NextConfig } from "next";const nextConfig: NextConfig = { experimental: { instrumentationHook: true, },};export default nextConfig;
Without this flag, the register() function in src/instrumentation.ts is dead code — the framework never calls it. The key must be spelled exactly instrumentationHook (not clientInstrumentationHook or instrumentation).
Step 7: Create the Next.js instrumentation hook
Next.js 16 supports an instrumentation.ts file that runs at server startup. This file initializes the tracer manager and tells the Langfuse exporter to boot. Create src/instrumentation.ts:
ts
import { TracerManager, HookManager } from "@reaatech/otel-genai-semconv-instrumentation";import pino from "pino";const logger = pino({ level: process.env.LOG_LEVEL || "info" });export const tracerManager = new TracerManager();const hookManager = new HookManager();hookManager.onEnd((ctx) => { logger.info({ spanContext: ctx.span.spanContext() }, "Span ended");});export { hookManager as HookManager };export async function register(): Promise<void> { if (process.env.NEXT_RUNTIME === "nodejs") { const { initObservability } = await import("./lib/langfuse-exporter.js"); initObservability(); }}
The register() function is guarded by process.env.NEXT_RUNTIME === "nodejs" because Next.js runs this module in both Node.js and Edge runtimes — only Node supports the OTel SDK. The dynamic import() inside the guard prevents the Node-only langfuse-exporter module from loading in Edge.
Step 8: Build the InstrumentedGrok client
This is the core of the observability system. InstrumentedGrok wraps the OpenAI SDK (pointed at xAI’s base URL) with cost telemetry, OTel spans, retry logic, circuit breaking, and streaming support. Create src/lib/instrumented-grok.ts:
ts
import OpenAI from "openai";import { wrapOpenAI, OpenAIWrapper, type WrappedOpenAI } from "@reaatech/llm-cost-telemetry-providers";import { TracerManager, ErrorHandler, RetryHandler, CircuitBreaker, instrumentStream, ChunkAggregator,} from "@reaatech/otel-genai-semconv-instrumentation";import { SpanBuilder, type LLMResponse,} from "@reaatech/otel-genai-semconv-core";import { type CostSpan } from "@reaatech/llm-cost-telemetry";import { normalizeRequest } from "./request-normalizer.js";import pino from "pino";
The constructor does three key things:
Creates an OpenAI SDK client pointed at https://api.x.ai/v1 with x-api-key in the default headers (xAI’s auth mechanism)
Calls OpenAIWrapper to wrap that client so every chat completion call emits a CostSpan with token usage, model, and timing data
Initializes the retry handler (exponential backoff with jitter), circuit breaker (opens after 5 failures, recovers after 60 seconds), and tracer manager
The chatCompletion method checks the circuit breaker first, then starts an OTel span, uses the retry handler to call the wrapped API, maps the response to the GenAI semantic convention format, and returns the completion, cost span, and trace ID. On error, it classifies the error type via ErrorHandler and records the failure in the circuit breaker.
The streamCompletion method uses instrumentStream from the instrumentation package to capture time-to-first-token (TTFT), chunk count, and output tokens, then uses ChunkAggregator to assemble the final response.
The getMetrics and resetMetrics methods let API routes expose aggregated cost and request data.
Expected output: A new InstrumentedGrok instance wraps the OpenAI SDK with full observability — every API call produces both a CostSpan and an OTel trace. The factory createInstrumentedGrok returns a singleton so route handlers share the same metrics buffer.
Step 9: Create the package index
Create src/index.ts to export the library’s public API:
ts
export { InstrumentedGrok, createInstrumentedGrok } from "./lib/instrumented-grok.js";export { estimateGrokCost, getGrokPricing, createGrokCostSpan } from "./lib/cost-tracker.js";export { normalizeRequest } from "./lib/request-normalizer.js";
Step 10: Create the API routes
Create three API routes under app/api/. Each route uses the singleton pattern from createInstrumentedGrok().
Expected output: All tests pass. numFailedTests=0, numTotalTests >= 3, and coverage thresholds (lines, branches, functions, statements) all at 90% or higher.
Step 13: Run type checking and linting
Verify the project compiles and passes linting:
terminal
pnpm typecheckpnpm lint
Expected output: Both commands exit 0 with no errors or warnings.
Next steps
Add per-tenant cost aggregation — extend createGrokCostSpan to pass tenant and feature context through the route handler, then group metrics in the Langfuse dashboard by tenant
Add alerting thresholds — wire the circuit breaker’s failureThreshold and recoveryTimeoutMs into a monitoring endpoint that alerts when Grok API calls start failing
Extend to streaming — the streamCompletion method is ready for use; add a streaming route POST /api/chat/stream that returns a Server-Sent Events (SSE) stream with real-time token-by-token output