Observability with OpenTelemetry on GCP
OpenTelemetry gives you a single, vendor-neutral way to emit traces, metrics, and logs — but that neutrality means the instrumentation code never talks to Google Cloud Trace or Cloud Monitoring directly. Every signal flows through an exporter to a Collector, and the Collector is where GCP-specific routing happens: the same OTel SDK code exports identically whether the Collector forwards to Cloud Trace, Jaeger, or Grafana Tempo, which is the whole point — you're not locked into one vendor's SDK. This guide is specifically the GCP side of that pipeline, since OpenTelemetry with Grafana already covers the self-hosted backend path.
The running example is a two-service GKE deployment (an API gateway calling an orders service) where a single request's trace shows both services' spans in Cloud Trace, the same request's error rate shows up as a Cloud Monitoring metric, and a deliberately slow database call in the orders service is visible as the long span in the trace waterfall. You'll see the three pillars as OTel signals, how the Collector routes them to Google's native backends, the GKE deployment shape (sidecar vs. gateway Collector), and why sampling decisions directly affect your Cloud Trace bill.
Traces, metrics, and logs as OTel signals
OpenTelemetry defines three signal types with a shared instrumentation model: traces are a tree of spans representing one request's path through your services, each span carrying a start/end time, attributes, and a parent-child relationship that lets a trace viewer reconstruct the whole call graph. Metrics are aggregated numeric measurements over time (request count, latency histogram, error rate) — cheaper to store and query at scale than traces, but they answer "how much/how often," not "what exactly happened in this one request." Logs are discrete timestamped events, and OTel's log API (newer and less universally adopted than tracing) lets a log line carry the same trace/span id as the request it happened during, which is what makes "click a slow trace, jump straight to its logs" possible instead of grepping by timestamp and hoping.
The practical discipline: instrument all three from the start using the same OTel SDK, with correlated ids (trace id, span id) attached to metrics and log lines wherever the emitting code has an active span — that correlation is what turns three separate observability surfaces into one investigation instead of three tools you cross-reference by hand. For the conceptual foundation this signal model rests on, see observability: logs, metrics, and traces.
Quick reference
- A span's parent-child relationship is what makes a trace a waterfall, not a flat list — set the correct parent context when a service calls another service.
- Metrics are cheap to keep at high resolution and long retention; traces are expensive per-event, which is exactly why sampling exists (see below).
- Attach
trace_id/span_idto every log line emitted inside an active span — that's the join key between Cloud Logging and Cloud Trace. - Auto-instrumentation libraries (
@opentelemetry/auto-instrumentations-node) cover HTTP, gRPC, and common database clients without manual span code for the common path.
Remember this
Traces show one request's path, metrics show aggregate volume and rate, and logs show discrete events — correlate all three with a shared trace id or you're maintaining three disconnected tools.
The Collector pipeline into Cloud Trace and Cloud Monitoring
Application code exports OTel signals over OTLP (the OpenTelemetry wire protocol) to a Collector — a separate process (not the application) that receives, optionally batches/filters/samples, and forwards to one or more backends. On GCP, the Collector's googlecloud exporter translates OTel traces into Cloud Trace's format and OTel metrics into Cloud Monitoring's format, so application code never needs a GCP-specific SDK — it speaks standard OTLP to the Collector, and only the Collector's config knows it's talking to Google Cloud.
This indirection is the actual value of the Collector layer: swapping backends (adding a second export target, or migrating off GCP later) is a Collector config change, not an application redeploy across every service. It also centralizes sampling and PII-scrubbing logic in one place instead of duplicating that logic in every service's instrumentation code.
Quick reference
- The
googlecloudexporter needs a service account with Cloud Trace Agent and Monitoring Metric Writer IAM roles — Workload Identity on GKE avoids shipping a key file. - Tail-based sampling (deciding after a trace completes) can keep 100% of error traces while sampling ordinary success traces down — impossible with simple head-based sampling.
- Run one Collector per cluster (or per node as a DaemonSet) rather than one Collector per pod — centralizing batching and sampling logic in fewer places is the point.
- The
batchprocessor reduces the number of export API calls, which matters for both latency and hitting Cloud Trace's per-project write quota.
Remember this
Application code exports plain OTLP to a Collector; only the Collector's exporter config knows about GCP — that's what makes the backend swappable without touching instrumented services.
Sidecar vs. gateway Collector on GKE
Two deployment shapes for the Collector on GKE, and they trade off differently. A sidecar Collector runs in every pod alongside the application container — lowest latency to the exporting app (localhost), and a Collector crash only affects that one pod's telemetry, but it multiplies Collector resource usage by pod count and duplicates sampling/config across every replica. A gateway Collector runs as its own Deployment (often behind a Service, or as a DaemonSet — one per node rather than per pod) that every application pod exports to over the cluster network — centralized config and far fewer Collector instances to run, at the cost of one more network hop and a shared failure domain if the gateway Collector itself falls behind.
For most teams, a DaemonSet Collector (one per node, shared by every pod on that node) is the practical middle: fewer instances than sidecars, but still local to the node rather than a fully centralized bottleneck. Start there; move to a fully centralized gateway only if you need one place to enforce cluster-wide sampling or scrubbing policy that a per-node DaemonSet can't coordinate.
Quick reference
- DaemonSet Collector (one per node) is the common default — balances resource overhead against avoiding a single centralized bottleneck.
- Sidecar Collectors add real per-pod memory/CPU overhead at scale — reserve them for services with strict latency requirements that can't tolerate even a local network hop.
- A fully centralized gateway Collector needs its own horizontal scaling and its own alerting — it's now a production dependency, not an implementation detail.
- Whatever the topology, set Collector resource requests/limits explicitly — an unbounded Collector competing for node resources with application pods is its own incident.
Remember this
Default to a DaemonSet Collector (one per node) on GKE — it avoids both per-pod overhead multiplication and a single centralized bottleneck, and only needs revisiting if you require cluster-wide policy enforcement a DaemonSet can't coordinate.
Why your sampling decision is a billing decision
Cloud Trace bills per span ingested past a free monthly quota, and Cloud Monitoring bills per time series and per sample for custom metrics — which means an observability decision ("sample every trace at 100%") is silently also a cost decision, and teams frequently discover this only when a traffic spike produces a billing spike with no corresponding incident. The realistic failure: a service under normal load exports acceptable trace volume, then a retry storm or a traffic spike multiplies request volume 20x, and 100% trace sampling multiplies Cloud Trace ingestion 20x in the same hour, without anyone having made an explicit decision to spend that much.
The fix is tail-based sampling with an explicit policy, not a fixed percentage applied blindly: always keep error traces and traces above a latency threshold (the ones you'll actually want to look at during an incident), and sample ordinary fast successful traces down to a small percentage — 100% of interesting traces, 5-10% of routine ones. Set a Cloud Billing budget alert on the Trace and Monitoring SKUs specifically, so a sampling misconfiguration surfaces as a billing alert within a day, not as a surprise on the next invoice.
Quick reference
- Tail-based sampling policies (keep 100% of errors/slow traces, sample the rest) give you the traces you need for incidents at a fraction of full-sampling cost.
- A traffic spike or retry storm multiplies trace volume proportionally under fixed-percentage sampling — model that in your sampling policy, not just your average-day traffic.
- Set a GCP Billing budget alert scoped to the Cloud Trace and Cloud Monitoring SKUs specifically, so a sampling regression is caught in near-real-time.
- Custom metrics (as opposed to GCP's built-in ones) are billed per time series — high-cardinality labels (a raw user id as a metric label) can blow up cost far faster than trace volume does.
Remember this
A sampling percentage is a cost lever, not just an observability tuning knob — use tail-based sampling to keep 100% of the traces you'd actually look at during an incident while capping the cost of routine traffic.
Key takeaway
Deploy the two-service example (API gateway → orders service) on a local Kind/GKE cluster with a DaemonSet OTel Collector exporting to Cloud Trace and Cloud Monitoring via the googlecloud exporter, using Workload Identity for the service account. Expected result: a request to the gateway produces one trace in Cloud Trace showing both services' spans, and the orders service's request count and p99 latency appear as Cloud Monitoring metrics within a minute. Then break it on purpose — add an artificial 2-second delay to the orders service's database call and confirm that span is visibly the longest bar in the trace waterfall, then verify your tail-sampling policy (kept because it now exceeds a latency threshold) retains that trace even at a 10% baseline sampling rate. Pass criterion: the slow trace is visible end to end with correct parent-child spans, the corresponding metric shows the latency increase, and a load test simulating 20x traffic confirms trace ingestion volume stays bounded by the sampling policy rather than scaling 20x with it.
Related Articles
Explore this topic