Assembling Observability with Antigravity × OpenTelemetry — Instrumentation, Sampling, Cardinality, and AI Anomaly Detection in Production
A record of running OpenTelemetry in production on a Node.js backend: measured instrumentation overhead, tail sampling configuration, avoiding cardinality explosion, and operating AI anomaly detection behind an approval gate.
The API I run as an indie developer went down at two in the morning.
I opened the logs and found exactly one useful fact: a 500 had been returned. Which request, which downstream dependency, how long it had waited before giving up — none of that was there. It took me three hours to find the cause, and the cause turned out to be embarrassingly simple: a timeout on an external API set far too generously.
Those three hours were interest, charged by the absence of monitoring.
Observability isn't logging. It's the state of being able to ask a question after the incident and get an answer. A trace tells you the path a request took. Metrics tell you the system's temperature. Logs tell you the particulars. Only when all three sit on the same timeline does the question become answerable at all.
OpenTelemetry is the shared language for that. The catch is the setup cost — instrumenting code, configuring exporters, wiring backends. Done by hand, a few days disappear before you see a single span. Antigravity's agents take over that assembly work.
What follows is the configuration I arrived at after putting OpenTelemetry into a Node.js/TypeScript backend and actually running it in production, along with a record of the places it broke. Not just how to add instrumentation — what happens after you do.
Target audience: Backend engineers with Node.js/TypeScript experience who want to strengthen production monitoring.
The Three Pillars of OpenTelemetry and Antigravity's Role
OpenTelemetry (OTel) is a CNCF open standard that provides a unified approach to traces, metrics, and logs. Antigravity's agents serve as a powerful automation layer for instrumenting all three pillars.
Traces (Distributed Tracing)
Traces visualize how requests flow through microservices. They're essential for identifying processing bottlenecks and understanding service dependencies.
Metrics
Metrics capture numerical data over time — CPU usage, memory consumption, request rates, error rates. They form the foundation for alerting thresholds and capacity planning.
Logs
Logs provide detailed records of application behavior. When correlated with trace IDs, you can filter logs to a specific request's journey across your entire system.
Antigravity's agents analyze your codebase to suggest optimal instrumentation points and auto-generate OpenTelemetry SDK configuration. This ensures comprehensive coverage of endpoints and database queries that manual instrumentation often misses.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Measured auto-instrumentation overhead (p99 up ~1.9x, throughput down 24%) and the tail-sampling config that recovers it
✦A real cardinality explosion (42,000 series down to 380) and how to split attributes between metrics and spans
✦A delegation table for agent vs. human decisions, and why auto-remediation stays approval-gated
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Start by having Antigravity's agent analyze your project structure, then install the OpenTelemetry dependencies.
# Run in Antigravity's terminal# Have the agent analyze your project structure@agents analyze the project structure and identify all HTTP endpoints,database queries, and external API calls that need instrumentation# Install OpenTelemetry packagesnpm install @opentelemetry/sdk-node \ @opentelemetry/api \ @opentelemetry/auto-instrumentations-node \ @opentelemetry/exporter-trace-otlp-http \ @opentelemetry/exporter-metrics-otlp-http \ @opentelemetry/sdk-metrics \ @opentelemetry/resources \ @opentelemetry/semantic-conventions
Next, have the Antigravity agent generate the OTel SDK initialization code.
// src/instrumentation.ts// OTel initialization code generated by the Antigravity agentimport { NodeSDK } from '@opentelemetry/sdk-node';import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';import { Resource } from '@opentelemetry/resources';import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';const resource = new Resource({ [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || 'my-api-service', [ATTR_SERVICE_VERSION]: process.env.npm_package_version || '1.0.0', 'deployment.environment': process.env.NODE_ENV || 'development',});const traceExporter = new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT + '/v1/traces',});const metricExporter = new OTLPMetricExporter({ url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT + '/v1/metrics',});const sdk = new NodeSDK({ resource, traceExporter, metricReader: new PeriodicExportingMetricReader({ exporter: metricExporter, exportIntervalMillis: 30000, // Export metrics every 30 seconds }), instrumentations: [ getNodeAutoInstrumentations({ '@opentelemetry/instrumentation-http': { ignoreIncomingPaths: ['/health', '/ready'], // Exclude health checks }, '@opentelemetry/instrumentation-express': { enabled: true }, '@opentelemetry/instrumentation-pg': { enabled: true }, '@opentelemetry/instrumentation-redis': { enabled: true }, }), ],});sdk.start();// Graceful shutdownprocess.on('SIGTERM', () => { sdk.shutdown() .then(() => console.log('OTel SDK shut down successfully')) .catch((err) => console.error('Error shutting down OTel SDK', err)) .finally(() => process.exit(0));});export default sdk;
Load this file before your application entry point to activate auto-instrumentation.
Auto-instrumentation covers standard operations, but business-critical logic requires custom spans and metrics. Ask the Antigravity agent to "add custom instrumentation to this service's critical business processes" and it will insert code at the appropriate points.
// src/services/payment.service.ts// Custom spans and metrics for payment processingimport { trace, metrics, SpanStatusCode } from '@opentelemetry/api';const tracer = trace.getTracer('payment-service');const meter = metrics.getMeter('payment-service');// Define custom metricsconst paymentCounter = meter.createCounter('payments.processed', { description: 'Total number of processed payments', unit: '1',});const paymentDuration = meter.createHistogram('payments.duration', { description: 'Time taken to process payments', unit: 'ms',});const activePayments = meter.createUpDownCounter('payments.active', { description: 'Number of currently active payment processes',});export async function processPayment( userId: string, amount: number, currency: string): Promise<PaymentResult> { // Start a custom span return tracer.startActiveSpan('payment.process', async (span) => { const startTime = Date.now(); activePayments.add(1); try { // Add attributes to the span (searchable in traces) span.setAttributes({ 'payment.user_id': userId, 'payment.amount': amount, 'payment.currency': currency, }); // Stripe API call (auto-instrumented as HTTP span) const result = await stripe.paymentIntents.create({ amount: Math.round(amount * 100), currency, metadata: { userId }, }); // Record success metrics paymentCounter.add(1, { status: 'success', currency, }); span.setStatus({ code: SpanStatusCode.OK }); return { success: true, intentId: result.id }; } catch (error) { // Record error metrics paymentCounter.add(1, { status: 'error', currency, error_type: error instanceof Error ? error.name : 'unknown', }); span.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.message : 'Payment failed', }); span.recordException(error as Error); throw error; } finally { activePayments.add(-1); paymentDuration.record(Date.now() - startTime, { currency }); span.end(); } });}
Correlating Logs with Traces
Observability reaches its full potential when traces, metrics, and logs are interconnected. Antigravity agents can automatically inject trace context into your existing logger configuration.
With this setup, you can grab a trace ID from an error log and instantly view every span associated with that request in Grafana Tempo or Jaeger. Questions like "which upstream service was slow when this error occurred?" become answerable in seconds.
Backend Configuration: Grafana Stack Integration
For visualizing collected telemetry data, we'll set up the Grafana Stack (Grafana + Tempo + Prometheus + Loki). Have the Antigravity agent generate the Docker Compose configuration.
This is the heart of the article. We'll use Antigravity agents to build a pipeline that detects anomalies from collected telemetry data and executes auto-remediation scripts.
Tell the Antigravity agent to "generate a Grafana dashboard JSON based on the metrics we're collecting" and it will produce a comprehensive dashboard including your custom metrics.
Import the generated JSON into Grafana's Dashboard Import and monitoring begins immediately.
Measuring what instrumentation costs
Instrumentation is not free. The moment I enabled auto-instrumentation, latency got visibly worse.
Below are median figures from three runs of autocannon -c 50 -d 30 against the same endpoint, on Node.js 22 in a 2-vCPU container, exporting over OTLP/gRPC to a sidecar Collector.
Configuration
p50 latency
p99 latency
RSS
Throughput
No instrumentation
8.4 ms
31 ms
112 MB
5,180 req/s
Auto-instrumentation, all spans exported
11.9 ms
58 ms
168 MB
3,940 req/s
Auto-instrumentation + 10% head sampling
9.1 ms
36 ms
131 MB
4,870 req/s
Auto-instrumentation + tail sampling (Collector)
9.0 ms
35 ms
129 MB
4,900 req/s
Exporting every span cost roughly 24% of throughput. The near-doubling of p99 comes from export batch flushes blocking the event loop.
The part worth pausing on: head sampling and tail sampling perform almost identically, but they preserve completely different information. Head sampling rolls the dice when a trace begins, so it discards roughly 90% of your failing 1% of requests. Those discarded traces are precisely the ones you want during an incident.
Tail sampling decides after the trace completes, based on whether it contains an error or exceeded a latency threshold. The Collector configuration looks like this.
decision_wait is how long the Collector waits for a trace's spans to arrive. Set it too low and you discard traces as "successful" before the error span shows up. I started at 2 seconds and systematically lost every trace containing an async background job. The measured p99 for those jobs was 6 seconds; raising the wait to 10 seconds fixed it.
I prefer this shape. Every error and every slow request survives; only the uneventful traces get thinned to 5%. Cost lands at roughly 8% of full export, and nothing an investigation needs is gone.
Cardinality explosion, and the invoice that announces it
The first thing to break in production is usually metric cardinality.
Every distinct value you attach as a metric label creates its own time series. Put a user ID in a label and you have one series per user.
So this, written carelessly, is dangerous:
// ❌ Dangerous: userId and raw path are high-cardinalityrequestCounter.add(1, { 'http.route': req.path, // /users/8a3f-… is its own label 'user.id': req.user.id, // one series per user});
Template the path, and move per-request identifiers onto the span instead. Metrics are a vessel for aggregation; spans are a vessel for individual events. Blur that boundary and the invoice will explain it to you.
// ✅ Keep metrics low-cardinalityrequestCounter.add(1, { 'http.route': req.route?.path ?? 'unknown', // /users/:id 'http.status_code': res.statusCode, 'service.version': process.env.APP_VERSION ?? 'dev',});// Identity belongs on the spanconst span = trace.getActiveSpan();span?.setAttribute('user.id', req.user.id);span?.setAttribute('request.id', req.headers['x-request-id'] as string);
In my case, emitting http.route as a raw path for three days grew the series count to roughly 42,000 and pushed Prometheus scrapes past 12 seconds, where they timed out. After templating: 380 series. Less than one hundredth.
It's worth adding a safety net in the Collector too.
processors: # Drop unexpected high-cardinality attributes attributes/scrub: actions: - key: user.id action: delete - key: session.id action: delete # Cap memory so a cardinality spike drops data instead of the Collector memory_limiter: check_interval: 1s limit_mib: 512 spike_limit_mib: 128
With memory_limiter in place, a cardinality explosion causes the Collector to shed data rather than die. An outage where the monitoring stack fails first is the least recoverable kind.
What I delegated to the agent, and what I kept
Where to draw the line with Antigravity's agents. Here is where mine ended up.
Task
Owner
Reasoning
Selecting and installing auto-instrumentation libraries
Agent
Dependency analysis is more accurate by machine; fewer gaps
Proposing custom span boundaries
Agent → human review
Proposals are sharp, but only a human knows which boundaries matter to the business
Choosing a sampling policy
Human
"What can we afford to lose" is a cost and risk call, not a technical one
Naming attributes / cardinality design
Human
Large billing impact, hard to reverse later
Generating the initial Grafana dashboard
Agent
An excellent first draft — usable as-is
Setting alert thresholds
Human
Tolerance for false alarms depends on team culture
First-pass anomaly classification
Agent
Good at removing noise; final judgment stays with a person
Applying auto-remediation in production
Human (approval-gated)
See below
I stopped short of fully automatic remediation because I tried it once and it frightened me.
The agent saw memory usage cross a threshold and scaled out. The actual problem was a memory leak. Scaling out hid the symptom for a few hours while the number of leaking instances grew. A machine can make an anomaly disappear; it has no notion of whether the anomaly deserved to disappear.
Now the agent posts a remediation proposal to Slack along with its evidence — the offending spans, the metric deltas, similar past incidents — and only what I approve gets executed. I still get woken at 2 a.m. just as often. But the time from waking to understanding went from three hours to about ten minutes.
Bring up instrumentation, sampling, backends, and anomaly detection simultaneously and you will not know which one broke. The work of introducing observability becomes, ironically, unobservable.
The second time around, this was my order.
Auto-instrumentation only, console exporter (half a day) — verify that spans emerge with correct parent-child relationships. No backend yet.
Add the Collector, ship to Tempo/Prometheus (one day) — network and resource problems surface here. Because step 1 is known-good, isolating them is fast.
Add sampling, measure the cost (one day) — run at full volume for a day first, then thin. The reduction becomes a number rather than a hope.
Add custom spans and business metrics (ongoing) — add one the moment you find yourself wanting to know how long something takes. Spans added preemptively mostly go unread.
Anomaly detection and remediation proposals (a month later) — only once two weeks of baseline has accumulated. Anomaly detection without data is a random number generator.
The month-long gap between steps 3 and 5 is deliberate. A detector without a baseline forces you to fill thresholds in from intuition. With two weeks of data, the distribution fills them in for you.
What that empty 2 a.m. log taught me is that monitoring cannot be added after the incident. The information you desperately want during an outage has to have been planted before it.
Antigravity's agents reliably reduce the labor of planting it. But three questions remain yours to answer: what you can afford to lose (sampling), what you choose to count (cardinality), and what may be fixed without you (remediation). Agents can own the implementation. They cannot own the judgment.
If you try one thing today, run auto-instrumentation with a console exporter and hit a single endpoint. Check that the parent-child relationships come out the way you expect. That thirty minutes makes the following month considerably lighter.
I'm still learning this as I operate it. If any of it helps someone else who gets woken at two in the morning, I'll be glad.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.