A customer emails support: "Our invoice shows more AI tokens than our own dashboard does." You open the logs. There are millions of lines from the Laravel API, the Node.js billing worker and the queue, none of them share an ID, and the one request that matters was probably dropped by the 1% head sampling you turned on last quarter to cut the tracing bill. Nothing is broken. You just cannot see anything.
I wrote this with help from AI tools and checked every command, config key and claim myself against the current OpenTelemetry docs. I run Loop Origin, where we handle server setup and maintenance for Laravel and Node.js SaaS platforms, and this is the observability setup I reach for when the budget is small and the incidents are not: OpenTelemetry everywhere, logs that carry trace IDs, and a collector that keeps the traces worth paying for.
📚 Series Roadmap — Production AI & SaaS Systems Engineering
- Part 1: Building Production MCP Servers & Tool-Calling AI Agents: Sandboxing, Schemas & Approval Gates
- Part 2: Zero-Downtime Database Migrations in Laravel & Node.js SaaS: Expand-Contract & Lock Timeouts
- Part 3: Multi-Tier Caching & Stampede Protection with Redis/Valkey: Singleflight Locks & SWR
- Part 4: SaaS Usage-Based Billing & Metering Architecture: Event Ingestion, Rollups & Stripe Invoicing
- Part 5 (This Post): Production Observability on a Budget: OpenTelemetry Traces, Structured Logs & Tail Sampling
- Part 6: LLM Gateway & FinOps Architecture for B2B SaaS: Semantic Caching, Routing & Tenant Quotas
How do you run OpenTelemetry in a Laravel and Node.js SaaS without a huge bill?
Direct answer: Instrument every service with the OpenTelemetry SDK and send 100% of spans to your own OpenTelemetry Collector. Generate request-rate, error and latency metrics there from all spans, then use tail sampling to keep every error, every slow trace and a small random baseline. Put trace IDs in JSON logs so logs and traces join.
Why head sampling fails the incidents you care about
Head sampling decides at the first span whether a trace is recorded, before anyone knows how the request will end. At 1%, you keep 1% of your errors and 1% of your slow checkouts. That is fine for a high-traffic homepage and useless for a billing worker that fails twice a day.
Tail sampling flips the order. The collector buffers all spans of a trace for a few seconds, waits for the trace to finish, then decides. The cost moves from your tracing vendor's storage bill to a little collector memory, which is usually the cheaper side of the trade.
The catch: every span of a trace has to reach the same collector instance, or each instance sees half a trace and makes half a decision. That shapes the whole architecture below.
The architecture
Laravel API ─┐ ┌─► tier-2 collector A ─┐
Node worker ─┼─► tier-1 collector(s) ──┤ (spanmetrics + ├─► traces backend
Queue jobs ─┘ loadbalancing by └─► tier-2 collector B ─┘ metrics backend
traceID tail_sampling)
On a small platform, tier 1 can be a collector sidecar or a single gateway; tier 2 can start as one instance. Split them when one box can no longer hold the decision buffer.
Tier 1: route by trace ID
# otel-tier1.yaml (opentelemetry-collector-contrib)
receivers:
otlp:
protocols:
grpc: { endpoint: 0.0.0.0:4317 }
http: { endpoint: 0.0.0.0:4318 }
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 80
spike_limit_percentage: 20
exporters:
loadbalancing:
routing_key: traceID
protocol:
otlp:
tls: { insecure: true } # private network only; use TLS across hosts
resolver:
dns:
hostname: otel-tier2.internal
port: 4317
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter]
exporters: [loadbalancing]
Tier 2: metrics from everything, traces from the interesting part
# otel-tier2.yaml
receivers:
otlp:
protocols:
grpc: { endpoint: 0.0.0.0:4317 }
connectors:
spanmetrics:
dimensions:
- name: http.route
- name: tenant.plan # low-cardinality only; never tenant.id
forward: {}
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 80
spike_limit_percentage: 20
filter/health:
error_mode: ignore
traces:
span:
- 'attributes["url.path"] == "/up"'
tail_sampling:
decision_wait: 10s
num_traces: 50000
expected_new_traces_per_sec: 200
policies:
- name: keep-errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: keep-slow
type: latency
latency: { threshold_ms: 1500 }
- name: keep-billing
type: string_attribute
string_attribute:
key: app.flow
values: [billing, metering]
- name: baseline
type: probabilistic
probabilistic: { sampling_percentage: 5 }
batch: {}
exporters:
otlphttp/traces:
endpoint: https://traces.example.internal
otlphttp/metrics:
endpoint: https://metrics.example.internal
service:
pipelines:
traces/in:
receivers: [otlp]
processors: [memory_limiter, filter/health]
exporters: [spanmetrics, forward]
traces/sampled:
receivers: [forward]
processors: [tail_sampling, batch]
exporters: [otlphttp/traces]
metrics:
receivers: [spanmetrics]
processors: [batch]
exporters: [otlphttp/metrics]
Three decisions matter here:
-
spanmetricsruns before sampling. Your request-rate, error and duration metrics are computed from every span, so dashboards and alerts stay accurate even though you store a fraction of the traces. This is what makes aggressive sampling safe. - Policies are OR'ed. A trace is kept if any policy says so: every error, every request over 1.5 s, every billing flow, plus 5% of everything else for "what does normal look like?" comparisons.
-
decision_waitmust exceed your longest normal trace. Spans that arrive after their trace's decision can be evaluated as a new trace unless the processor's decision cache is configured, and you end up with fragments. Sizenum_tracesfor roughlydecision_wait × traces per second, with headroom, and watch collector memory.
The health-check filter is a cheap win. Load balancer probes hitting Laravel's /up route every few seconds produce a lot of perfectly boring spans. Check which attribute your instrumentation actually emits (url.path in current semantic conventions, older libraries may use http.target) before you trust the filter.
Instrumenting Laravel
The PHP SDK uses a C extension for automatic hooks plus Composer packages:
pecl install opentelemetry
composer require open-telemetry/sdk open-telemetry/exporter-otlp \
open-telemetry/opentelemetry-auto-laravel php-http/guzzle7-adapter
; php.ini / FPM pool env
extension=opentelemetry.so
# .env or FPM pool
OTEL_PHP_AUTOLOAD_ENABLED=true
OTEL_SERVICE_NAME=api
OTEL_TRACES_EXPORTER=otlp
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-tier1.internal:4318
OTEL_TRACES_SAMPLER=parentbased_always_on
OTEL_PROPAGATORS=tracecontext,baggage
parentbased_always_on is deliberate: the SDK sends everything, the collector decides. Then make every log line carry the trace:
// app/Logging/AddTraceContext.php, registered with 'tap' => [AddTraceContext::class]
namespace App\Logging;
use Illuminate\Log\Logger;
use Monolog\Formatter\JsonFormatter;
use Monolog\LogRecord;
use OpenTelemetry\API\Trace\Span;
class AddTraceContext
{
public function __invoke(Logger $logger): void
{
foreach ($logger->getHandlers() as $handler) {
$handler->setFormatter(new JsonFormatter());
$handler->pushProcessor(function (LogRecord $record): LogRecord {
$ctx = Span::getCurrent()->getContext();
if ($ctx->isValid()) {
$record->extra['trace_id'] = $ctx->getTraceId();
$record->extra['span_id'] = $ctx->getSpanId();
}
return $record;
});
}
}
}
Queued jobs lose context unless you carry it. When the API dispatches work to the Node.js billing worker from Part 4, I inject the W3C traceparent into the payload:
use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
$carrier = [];
TraceContextPropagator::getInstance()->inject($carrier);
Redis::rpush('usage:report', json_encode(['tenant_id' => $tenantId, 'otel' => $carrier]));
Instrumenting Node.js
// instrumentation.ts, loaded with: node --require ./dist/instrumentation.js dist/worker.js
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
new NodeSDK({
serviceName: 'billing-worker',
traceExporter: new OTLPTraceExporter(), // reads OTEL_EXPORTER_OTLP_ENDPOINT
instrumentations: [getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': { enabled: false }, // noisy, rarely useful
})],
}).start();
// worker.ts: continue the trace started in Laravel
import { context, propagation, trace, SpanStatusCode } from '@opentelemetry/api';
const tracer = trace.getTracer('billing-worker');
export async function handle(job: { tenant_id: number; otel?: Record<string, string> }) {
const parent = propagation.extract(context.active(), job.otel ?? {});
return context.with(parent, () =>
tracer.startActiveSpan('usage.report', async (span) => {
span.setAttribute('app.flow', 'billing');
try { await reportUsage(job.tenant_id); }
catch (e) { span.recordException(e as Error); span.setStatus({ code: SpanStatusCode.ERROR }); throw e; }
finally { span.end(); }
}));
}
The auto-instrumentation bundle includes Pino and Winston integrations that add trace_id and span_id to log records, so the same join works on both sides. Setting app.flow=billing is what lets the keep-billing policy keep the whole trace, including the Laravel request that started it. That answers the support email: one trace ID, from the API call to the Stripe meter event.
Choosing a sampling strategy
| Strategy | Where it decides | Keeps all errors? | Metrics accuracy | Cost driver | When I use it |
|---|---|---|---|---|---|
| No sampling | Nowhere | Yes | Exact | Backend storage grows with traffic | Low traffic, early stage |
| Head sampling (e.g. 10%) | SDK, at trace start | No, ~10% | Skewed unless metrics come from elsewhere | Lower storage, blind to rare failures | Very high traffic, uniform requests |
| Tail sampling + spanmetrics | Collector, after trace ends | Yes | Exact (computed pre-sampling) | Collector RAM, small storage | Most B2B SaaS |
| Logs only, no tracing | n/a | n/a | Depends on log parsing | Log ingestion volume | Never again, for me |
Budget levers beyond sampling
- Drop debug logs in production at the source, not in the pipeline; you pay to ship what you later delete.
-
Keep metric dimensions low-cardinality. A
tenant.iddimension onspanmetricscreates one series per tenant per route. Put tenant IDs on spans, not on metrics. - Set retention per signal. Sampled traces for days, aggregated metrics for months, raw logs as short as your compliance allows.
- Watch the collector itself. It exposes its own telemetry; alert on dropped spans and memory-limiter refusals.
Diagnostic checklist
- Can you go from any error log line to its full trace by copying
trace_id? - Does a trace started in Laravel continue through the queue into the Node.js worker?
- Are all spans of a trace routed to the same tail-sampling collector?
- Is
decision_waitlonger than your slowest normal trace? - Are RED metrics generated before sampling?
- Does every policy you need (errors, latency, key flows) exist, plus a small baseline?
- Are health checks and other high-volume, low-value spans filtered?
- Are metric dimensions free of IDs, emails and URLs with parameters?
- Is the SDK sampler
parentbased_always_onso the collector sees everything? - Are collector drop and memory metrics alerting someone?
FAQ
Do I need a vendor to start?
No. The collector is open source and exports OTLP, so you can start with self-hosted backends such as Grafana Tempo, Jaeger or an OTLP-capable vendor's free tier, and switch later without touching application code.
How much memory does tail sampling need?
It depends on trace size, traffic and decision_wait. Measure it: run with memory_limiter, load-test at your peak traces per second, and size num_traces from what you observe rather than guessing.
Does sending 100% of spans to the collector slow PHP-FPM down?
The SDK batches exports, but every span still costs a little CPU in the request. Disable instrumentations you do not read, and keep the collector on the same host or network to keep export latency low.
Will tail sampling break my trace-based alerts?
It can if alerts count stored traces. Alert on the spanmetrics output instead, which sees every span.
How do I trace an LLM call inside a request?
Wrap it in a span with attributes for model, token counts and latency. Avoid storing prompts or outputs on spans; they can contain customer data and they inflate storage.
Is OpenTelemetry logging stable enough to replace my log shipper?
It is supported in many SDKs and the collector, but maturity differs by language. My conservative path is JSON logs with trace IDs to stdout, shipped as before, and moving to OTLP logs per service once its SDK is stable.
Next in this series, Part 6 covers LLM gateway and FinOps architecture: semantic caching, model routing and per-tenant token quotas, with the spans from this post as its cost ledger.
Follow & Connect
- Website: Loop Origin
- LinkedIn: Loop Origin
- X: Muhammad Ali Mughal (@AliMughal887)
- GitHub: Loop Origin (@LoopOrigin)

Top comments (0)