<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Tracing | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/tracing/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/tracing/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Fri, 18 Sep 2026 10:08:42 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>Tracing | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/tracing/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Instrument Once, Export Anywhere: OpenTelemetry on AWS With ADOT</title>
		<link>https://john-nessime.com/blog/devops/opentelemetry-on-aws-adot/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 01 Sep 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[ADOT]]></category>
		<category><![CDATA[Amazon ECS]]></category>
		<category><![CDATA[Amazon EKS]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cardinality]]></category>
		<category><![CDATA[CloudWatch]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[OpenTelemetry]]></category>
		<category><![CDATA[OTLP]]></category>
		<category><![CDATA[Semantic Conventions]]></category>
		<category><![CDATA[SigV4]]></category>
		<category><![CDATA[Tail Sampling]]></category>
		<category><![CDATA[Tracing]]></category>
		<category><![CDATA[Transaction Search]]></category>
		<category><![CDATA[Vendor Lock-In]]></category>
		<category><![CDATA[X-Ray]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=523</guid>

					<description><![CDATA[<p>A pipeline that returns 200 is not a pipeline that works. The four decisions behind running OpenTelemetry on AWS with ADOT, the CloudWatch OTLP endpoint limits that drop data without erroring, and how to catch the failures before a colleague does.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/opentelemetry-on-aws-adot/">Instrument Once, Export Anywhere: OpenTelemetry on AWS With ADOT</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">A colleague messages you: &#8220;checkout fell off the service map.&#8221; The service is fine. It&#8217;s serving traffic, the collector pod is <code>Running</code>, the exporter queue is empty, and nothing in the collector log reads like an error. But a chunk of the spans that left the application never showed up in CloudWatch.</p>



<p class="wp-block-paragraph">That&#8217;s the signature failure of telemetry pipelines on AWS. Transport worked, authentication worked, the endpoint answered <code>200</code>. It kept part of the payload and dropped the rest, because something in the batch broke a limit your collector has no idea exists.</p>



<p class="wp-block-paragraph">Getting started with OpenTelemetry on AWS is not the hard part. AWS Distro for OpenTelemetry (ADOT) will have you shipping traces in an afternoon. The hard part is four decisions underneath it: where the collector sits, which door telemetry uses to get into AWS, what happens to your metrics on the way through, and whether a second backend later costs you a config line or a migration. This post covers those four decisions and how each one fails without announcing itself.</p>



<h2 class="wp-block-heading">What ADOT actually buys you</h2>



<p class="wp-block-paragraph">ADOT is not a separate protocol or agent. It&#8217;s a downstream build of the upstream OpenTelemetry Collector plus AWS-flavoured SDKs, tested by AWS and covered by AWS Support. The config syntax is the upstream syntax, so a config written for vanilla OTel runs on ADOT and vice versa. The difference is which components are compiled in: ADOT ships the X-Ray exporter, the CloudWatch EMF exporter, the SigV4 extension, and ECS metric receivers already there.</p>



<p class="wp-block-paragraph">So the choice is narrower than it looks. Take ADOT for a build someone else validates and supports. Take upstream Contrib if you need a component ADOT hasn&#8217;t bundled, or you run one collector image across AWS, another cloud, and bare metal. Neither choice touches your instrumentation, which is the entire point.</p>



<h2 class="wp-block-heading">Decision one: where the collector runs</h2>



<p class="wp-block-paragraph">Three shapes, trading the same three things: blast radius, cost, and how much processing happens before data leaves your network.</p>



<h3 class="wp-block-heading">Sidecar</h3>



<p class="wp-block-paragraph">One collector container per ECS task or pod. The application talks to <code>localhost</code>, so no service discovery and no network hop to get wrong, and failure stays contained to one workload. The cost: you pay for that container everywhere, batches stay small because batching is per-instance, and any config change is a redeploy of every task.</p>



<h3 class="wp-block-heading">Agent plus gateway</h3>



<p class="wp-block-paragraph">A light collector per node (a DaemonSet on EKS) forwarding to a small pool of gateways. This is the shape I reach for first on anything past a handful of services. The agent does host-level enrichment, the gateway does the expensive work: large batches, tail sampling, redaction, fan-out. Config changes hit the gateway only.</p>



<p class="wp-block-paragraph">The catch: a gateway pool is a thing you now operate. It needs autoscaling and its own alerting, and undersized it starts refusing data at exactly the moment you have an incident and volume spikes.</p>



<h3 class="wp-block-heading">No collector at all</h3>



<p class="wp-block-paragraph">The ADOT SDKs can export straight to the CloudWatch OTLP endpoints, signing requests with credentials already available to the process. For a handful of Lambda functions this is the right answer: nothing to run, nothing to scale, no extra container in the cold start path. What you give up is control. Batching, retries, sampling policy, redaction, and backend routing all move into application processes and their environment variables, so changing any of them is a fleet redeploy rather than a config push. Good starting point, poor steady state.</p>



<h2 class="wp-block-heading">Decision two: which door into AWS</h2>



<p class="wp-block-paragraph">Two ways to hand telemetry to AWS, and they behave very differently.</p>



<p class="wp-block-paragraph">The older path uses AWS-specific exporters. <code>awsxray</code> converts OTLP spans into X-Ray segment documents and calls the X-Ray API. <code>awsemf</code> converts OTLP metrics into CloudWatch Embedded Metric Format and writes them as log events. Both work, both are well trodden, and both reshape your data into an AWS-native format on the way out.</p>



<p class="wp-block-paragraph">The newer path is native OTLP. CloudWatch exposes OTLP endpoints per signal, reached with the plain <code>otlphttp</code> exporter and a SigV4 signer. Same exporter you&#8217;d point at Grafana Cloud or Honeycomb, different URL and authenticator. That&#8217;s what &#8220;export anywhere&#8221; means in practice, and it&#8217;s why I default to it on new work.</p>



<p class="wp-block-paragraph">The endpoints follow a per-service pattern:</p>



<ul class="wp-block-list">
<li>Traces: <code>https://xray.&lt;region&gt;.amazonaws.com/v1/traces</code></li>

<li>Metrics: <code>https://monitoring.&lt;region&gt;.amazonaws.com/v1/metrics</code></li>

<li>Logs: <code>https://logs.&lt;region&gt;.amazonaws.com/v1/logs</code></li>
</ul>



<p class="wp-block-paragraph">Three constraints there will each cost you an afternoon if you don&#8217;t know them going in.</p>



<p class="wp-block-paragraph"><strong>HTTP only.</strong> No gRPC. If your collector exports over <code>otlp</code> on 4317 you can&#8217;t just change the URL, you have to switch exporters. Receiving gRPC from applications is fine, it&#8217;s the outbound leg that must be HTTP.</p>



<p class="wp-block-paragraph"><strong>SigV4 required.</strong> That means the <code>sigv4auth</code> extension, configured per signal because the signing service name differs: <code>xray</code>, <code>monitoring</code>, and <code>logs</code> respectively. Bearer tokens are an alternative for metrics and logs, but not for traces.</p>



<p class="wp-block-paragraph"><strong>Logs need headers, not just a URL.</strong> Target log group and stream travel in <code>x-aws-log-group</code> and <code>x-aws-log-stream</code> headers. Omit them and the request has nowhere to land.</p>



<p class="wp-block-paragraph">A working shape for traces and logs looks like this:</p>



<pre class="wp-block-code"><code>extensions:
  sigv4auth/traces:
    region: "us-east-1"
    service: "xray"
  sigv4auth/logs:
    region: "us-east-1"
    service: "logs"

exporters:
  otlphttp/traces:
    compression: gzip
    traces_endpoint: https://xray.us-east-1.amazonaws.com/v1/traces
    auth:
      authenticator: sigv4auth/traces

  otlphttp/logs:
    compression: gzip
    logs_endpoint: https://logs.us-east-1.amazonaws.com/v1/logs
    headers:
      x-aws-log-group: MyApplicationLogs
      x-aws-log-stream: default
    auth:
      authenticator: sigv4auth/logs

service:
  extensions: [sigv4auth/traces, sigv4auth/logs]
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [otlphttp/traces]
    logs:
      receivers: [otlp]
      exporters: [otlphttp/logs]</code></pre>



<p class="wp-block-paragraph">Note <code>traces_endpoint</code> and <code>logs_endpoint</code> rather than <code>endpoint</code>. This one bites people. The <code>endpoint</code> setting is a <em>base URL</em> and the exporter appends the signal path itself, so <code>endpoint: https://monitoring.us-east-1.amazonaws.com/v1/metrics</code> actually requests <code>/v1/metrics/v1/metrics</code>. The per-signal settings take a full path. If you&#8217;re getting 404s from a URL you&#8217;re certain is right, this is why.</p>



<p class="wp-block-paragraph">Also worth a comment in your config: upstream has renamed the component to <code>otlp_http</code> and <code>otlphttp</code> is now a deprecated alias scheduled for removal. Both work today.</p>



<h3 class="wp-block-heading">Traces need Transaction Search turned on first</h3>



<p class="wp-block-paragraph">This is the single most common reason a correctly configured trace pipeline produces nothing. The X-Ray OTLP endpoint requires Transaction Search to be enabled on the account, which redirects span ingestion into CloudWatch Logs:</p>



<pre class="wp-block-code"><code>aws xray update-trace-segment-destination --destination CloudWatchLogs</code></pre>



<p class="wp-block-paragraph">Spans then land in a log group named <code>aws/spans</code>, with a percentage indexed in X-Ray as trace summaries for search. The default index rate is one percent, enough to find traces while all spans stay queryable as structured logs. The caller needs <code>xray:UpdateTraceSegmentDestination</code> and <code>xray:UpdateIndexingRule</code> plus log group creation rights.</p>



<p class="wp-block-paragraph">Two consequences to plan for. Span ingestion is billed separately from log ingestion, so it&#8217;s a line item rather than a rounding error. And AWS recommends <code>always_on</code> sampling in the SDK on this path, because the indexing rule already handles reduction. Sample in both places and your service map goes patchy.</p>



<h3 class="wp-block-heading">The X-Ray SDK clock is running</h3>



<p class="wp-block-paragraph">The dates matter here, so plainly: the X-Ray SDKs and daemon entered maintenance mode on 25 February 2026 (security fixes only, no new instrumentation support), with end of support on 25 February 2027. The X-Ray <em>service</em> is fine and still gaining features. It&#8217;s the client libraries and the UDP daemon winding down. If you run <code>aws-xray-sdk</code> and a daemon sidecar, that is technical debt with a published expiry, and either the collector or the CloudWatch agent replaces the daemon.</p>



<h2 class="wp-block-heading">Decision three: metrics, temporality, and the cardinality bill</h2>



<p class="wp-block-paragraph">Metrics are where the silent drops live, because collector defaults and endpoint limits disagree.</p>



<p class="wp-block-paragraph">The metrics endpoint caps a single request at 1 MB uncompressed and 1,000 datapoints, counted across resource, scope, and metric levels combined. The upstream batch processor&#8217;s default is far larger. Leave it alone and you build oversized requests, and the response to an oversized or partially invalid request is not always a clean failure: it can come back <code>200</code> with some metrics accepted and others rejected or throttled. So set the batch size deliberately. AWS&#8217;s own examples use a conservative value:</p>



<pre class="wp-block-code"><code>processors:
  batch:
    send_batch_size: 200
    timeout: 10s</code></pre>



<p class="wp-block-paragraph">The other limits shape attribute design more than config:</p>



<ul class="wp-block-list">
<li>150 labels maximum across resource, scope, and datapoint attributes per datapoint</li>

<li>40 KB combined label and value size per series per datapoint</li>

<li>One million new series creatable per ten-minute window, per account</li>

<li>Timestamps no more than ten minutes in the future or fourteen days in the past</li>
</ul>



<p class="wp-block-paragraph">That new-series ceiling catches teams out. Attach a request ID, a session ID, or a raw URL path to a metric attribute and every request mints a fresh series. You&#8217;ll hit a million faster than you expect, and it presents as metrics randomly going missing rather than a quota error. Unbounded identifiers belong on spans and logs. Metric attributes should be values you could enumerate on a whiteboard.</p>



<p class="wp-block-paragraph">Temporality is the other conscious decision. The OpenTelemetry SDK spec defaults to cumulative, while CloudWatch&#8217;s metric model is delta-shaped: it wants what happened this period, not the total since your process booted. Counters that look like ever-climbing staircases instead of rates are that mismatch. The <code>cumulativetodelta</code> processor converts in the pipeline, keeping the decision in the collector rather than scattered across SDK environment variables in every service.</p>



<p class="wp-block-paragraph">Decide once and centrally either way. Mixed temporality across one account produces dashboards that are subtly wrong for months before anyone notices. And once volume climbs, pointing a cost tool such as Vantage or CloudZero at the CloudWatch line items earns its setup time, because telemetry spend grows in steps nobody approved.</p>



<h2 class="wp-block-heading">Decision four: a second backend without a second instrumentation</h2>



<p class="wp-block-paragraph">Here&#8217;s the payoff. A collector pipeline holds multiple exporters, and adding one is a config change:</p>



<pre class="wp-block-code"><code>service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlphttp/traces, otlphttp/vendor]</code></pre>



<p class="wp-block-paragraph">The same spans now reach CloudWatch and Grafana Cloud, Honeycomb, Datadog, or a self-hosted Tempo, with no application redeploy and no second agent. That&#8217;s the cashable value of instrumenting once against an open protocol: the switching cost of a backend drops from a migration project to a pull request.</p>



<p class="wp-block-paragraph">Be honest about the price. You pay ingest twice, and egress from the gateway is real money at volume. Collector memory scales with exporter queue count, so a fan-out gateway needs headroom. And if one backend slows, backpressure can reach the pipeline feeding the other, which argues for separate pipelines per destination when their reliability differs.</p>



<p class="wp-block-paragraph">It works in reverse too. Because the metrics and logs endpoints accept bearer tokens, a machine with no AWS credentials at all can ship into CloudWatch: a CI runner, another cloud, or a VPS at a provider like Contabo or InterServer running part of your stack. One collector config, one destination, regardless of who owns the hardware. Traces are the exception and still require SigV4.</p>



<h2 class="wp-block-heading">Why OpenTelemetry on AWS fails quietly</h2>



<p class="wp-block-paragraph">Most breakages here don&#8217;t throw. How to recognise the common ones:</p>



<h3 class="wp-block-heading">Data is missing but nothing errors</h3>



<p class="wp-block-paragraph">Almost always a limit breach inside an accepted request. Check batch sizes against the per-signal caps first, then attribute counts. Turn on the collector&#8217;s internal telemetry and compare the exporter&#8217;s sent counters against its failed counters. If sent looks healthy and data is still missing, the loss is happening server-side after acceptance, which narrows it to limits.</p>



<h3 class="wp-block-heading">403 with a signature mismatch</h3>



<p class="wp-block-paragraph">A SigV4 problem, not an IAM problem. The usual cause is the wrong signing service name in the extension, since <code>xray</code>, <code>monitoring</code>, and <code>logs</code> are not interchangeable. If you sign requests yourself rather than using the extension, note that the traces endpoint is stricter about which headers land in the signed set, so sign a minimal stable set rather than everything the HTTP client added.</p>



<h3 class="wp-block-heading">Backfilled data vanishes</h3>



<p class="wp-block-paragraph">Every endpoint enforces a timestamp window, and fourteen days in the past is the outer edge for all three signals. Replaying an old queue past that boundary gets rejected. Not a bug, but it looks like one when the replay job appears to succeed and produces nothing.</p>



<h3 class="wp-block-heading">Traces arrive but the service map is empty</h3>



<p class="wp-block-paragraph">Usually a missing or inconsistent <code>service.name</code> resource attribute, or double sampling between the SDK and the indexing rule. Confirm what&#8217;s actually leaving the collector before hunting in the console: add the <code>debug</code> exporter to a copy of the pipeline, set <code>verbosity: detailed</code>, and read the resource attributes on real spans.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Pointing an OTLP gRPC exporter at a CloudWatch endpoint. They&#8217;re HTTP only, and the error won&#8217;t say so clearly.</li>

<li>Using <code>endpoint</code> with a full signal path instead of the per-signal <code>traces_endpoint</code>, <code>metrics_endpoint</code>, or <code>logs_endpoint</code>.</li>

<li>Leaving the batch processor at its default size on the metrics pipeline.</li>

<li>Sampling in the SDK <em>and</em> relying on Transaction Search indexing rules, halving visibility twice over.</li>

<li>Putting request IDs, user IDs, or raw paths into metric attributes.</li>

<li>Running a gateway pool with no alerting on the collector&#8217;s own health, so saturation is invisible until you need it.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Keep instrumentation vendor-neutral. Plain OpenTelemetry SDK APIs, with the collector owning every AWS-specific decision.</li>

<li>Set <code>service.name</code>, <code>service.version</code>, and <code>deployment.environment</code> as resource attributes everywhere. Almost every correlation feature depends on them.</li>

<li>Alert on exporter failure counters and queue depth, and route those alerts somewhere that doesn&#8217;t depend on the pipeline being healthy.</li>

<li>Version the collector config in Git and deploy it like application code, with a staging pipeline you can break safely.</li>

<li>Redact in a processor before export. Once telemetry reaches a backend, removing it is a support ticket.</li>

<li>Test a second exporter early, even to a throwaway account. A vendor migration is the wrong time to learn the fan-out path doesn&#8217;t work.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Do I still need a collector if CloudWatch accepts OTLP directly?</h3>



<p class="wp-block-paragraph">Not to get data in, no. You need one to control what happens before it leaves: batching to fit endpoint limits, tail sampling, redaction, and fan-out. Small serverless estates can reasonably skip it. Anything with a dozen services will want one.</p>



<h3 class="wp-block-heading">Can I send OTLP to CloudWatch over gRPC?</h3>



<p class="wp-block-paragraph">No. The CloudWatch OTLP endpoints are HTTP 1.1 only, accept binary or JSON payloads, and support gzip or no compression. Your collector can still receive gRPC from applications, it just can&#8217;t forward over it.</p>



<h3 class="wp-block-heading">What&#8217;s the difference between ADOT and the upstream collector?</h3>



<p class="wp-block-paragraph">Same codebase, different build. ADOT is AWS&#8217;s tested distribution with AWS components bundled and AWS Support behind it; upstream Contrib carries a wider component set and moves faster. Configuration is compatible either way, so it&#8217;s a support and packaging choice, not an architectural one.</p>



<h3 class="wp-block-heading">Do I have to enable Transaction Search to send traces?</h3>



<p class="wp-block-paragraph">For the X-Ray OTLP endpoint, yes. It&#8217;s a prerequisite, and enabling it routes span ingestion through CloudWatch Logs. Using the <code>awsxray</code> exporter against the classic X-Ray API instead doesn&#8217;t require it, but you give up the span-level analytics Transaction Search provides.</p>



<h3 class="wp-block-heading">Can I ship telemetry to CloudWatch from outside AWS?</h3>



<p class="wp-block-paragraph">For metrics and logs, yes, using bearer token authentication instead of SigV4, which removes the need for AWS credentials on the host. Traces still require SigV4. Never hardcode the token in the config; read it from a mounted secret file or an injected environment variable.</p>



<h3 class="wp-block-heading">Is it urgent to migrate off the X-Ray SDK?</h3>



<p class="wp-block-paragraph">Not an emergency, but it&#8217;s on a clock: maintenance mode now, end of support 25 February 2027. Existing applications keep working, they just won&#8217;t get new library instrumentation. Plan the migration on your schedule rather than someone else&#8217;s.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">A pipeline that returns <code>200</code> is not a pipeline that works. Judge every design decision in OpenTelemetry on AWS against one question: when this drops data, do I find out from a dashboard or from a colleague asking why a service vanished off the map?</p>



<p class="wp-block-paragraph">Size batches against the published limits, keep unbounded identifiers off metric attributes, decide temporality in one place, and instrument with plain OpenTelemetry so the export target stays a config line. Do that and &#8220;instrument once, export anywhere&#8221; stops being a slogan and becomes a property you can test.</p>



<h2 class="wp-block-heading">Need help with your OpenTelemetry pipeline on AWS?</h2>



<p class="wp-block-paragraph">Most of this work is unglamorous and specific, which is why it gets deferred. Things I can help with:</p>



<ul class="wp-block-list">
<li>Auditing an ADOT or upstream collector config for silent drops, oversized batches, and limit breaches</li>

<li>Designing collector topology for ECS, EKS, or Lambda, including gateway sizing and autoscaling</li>

<li>Migrating X-Ray SDK and daemon workloads to OpenTelemetry ahead of end of support</li>

<li>Cutting telemetry spend through sampling policy, cardinality control, and attribute pruning</li>

<li>Dual export to CloudWatch and a third-party backend, so a future switch is a config change</li>

<li>Meta-monitoring that tells you the pipeline is broken before your users do</li>
</ul>



<p class="wp-block-paragraph">Send me a collector config, an exporter log, or a screenshot of the gap in your dashboard and I&#8217;ll tell you what I&#8217;d look at first.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/opentelemetry-on-aws-adot/">Instrument Once, Export Anywhere: OpenTelemetry on AWS With ADOT</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Metrics, Logs and Traces: What Each One Answers</title>
		<link>https://john-nessime.com/blog/devops/metrics-logs-traces/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Fri, 31 Jul 2026 22:54:33 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Web Performance]]></category>
		<category><![CDATA[Alerting]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Logging]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[OpenTelemetry]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Tracing]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=52</guid>

					<description><![CDATA[<p>Metrics tell you that something is wrong. Traces tell you where. Logs tell you why. Use them in that order and each step narrows the search — start with logs and you are grepping a firehose. Here is what each one is actually for, and the field that connects all three.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/metrics-logs-traces/">Metrics, Logs and Traces: What Each One Answers</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Checkout is slow. Somebody says it in Slack, and within a minute three people are looking at three different screens. One is scrolling a dashboard where every panel looks normal. One is grepping logs for the word &#8220;error&#8221; and finding nothing. One is reading a trace for a request that completed in 40ms and wondering why they bothered.</p>



<p class="wp-block-paragraph">Nobody is doing anything wrong. They are just using the wrong tool for the question at hand, and none of them has said out loud what question they are trying to answer.</p>



<p class="wp-block-paragraph">Metrics, logs and traces get called the three pillars of observability, which makes them sound like three views of the same thing. They are not. They answer three different questions, and knowing which question you are asking is most of the skill.</p>



<h2 class="wp-block-heading">The one-line version</h2>



<ul class="wp-block-list">
<li><strong>Metrics</strong> tell you <em>that</em> something is wrong, and when it started.</li>

<li><strong>Traces</strong> tell you <em>where</em> in the system it is wrong.</li>

<li><strong>Logs</strong> tell you <em>why</em> that particular thing went wrong.</li>
</ul>



<p class="wp-block-paragraph">That ordering is also the order you should use them in during an incident. Starting with logs is the most common mistake, and it is why people end up grepping for twenty minutes before discovering the problem was a downstream service that never logged anything at all.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Metrics: numbers over time, and the cost of a label</h2>



<p class="wp-block-paragraph">A metric is a number sampled repeatedly. Requests per second, memory used, queue depth. They are cheap to store because each sample is a timestamp and a value, and they aggregate well — which is exactly what makes them right for dashboards and alerts, and wrong for investigating one specific user&#8217;s problem.</p>



<pre class="wp-block-code"><code># Error rate as a proportion of all requests, per service
sum(rate(http_requests_total{status=~"5.."}[5m])) by (service)
  /
sum(rate(http_requests_total[5m])) by (service)</code></pre>



<p class="wp-block-paragraph">That query is answerable in milliseconds across months of history. No log search comes close.</p>



<h3 class="wp-block-heading">Averages lie, and they lie in a specific direction</h3>



<p class="wp-block-paragraph">An average response time of 200ms is compatible with 95% of requests at 50ms and 5% at three seconds. The people having a terrible time are invisible in the mean, and they are the ones who complain.</p>



<pre class="wp-block-code"><code># p99 latency by endpoint
histogram_quantile(0.99,
  sum(rate(http_request_duration_seconds_bucket[5m])) by (le, endpoint)
)</code></pre>



<p class="wp-block-paragraph">Alert on p95 or p99, look at p50 to understand the typical case, and be aware that averaging percentiles across instances is mathematically meaningless — you have to aggregate the histogram buckets first, which is what the query above does.</p>



<h3 class="wp-block-heading">Cardinality is the thing that breaks Prometheus</h3>



<p class="wp-block-paragraph">Every unique combination of label values creates a separate time series. Add a label with unbounded values and you multiply your storage and memory by the number of distinct values.</p>



<pre class="wp-block-code"><code># Fine. A handful of endpoints, a handful of statuses.
http_requests_total{endpoint="/checkout", status="200"}

# Catastrophic. One time series per user, forever.
http_requests_total{endpoint="/checkout", user_id="8a3f...", request_id="..."}</code></pre>



<p class="wp-block-paragraph">User IDs, request IDs, session tokens, full URLs with parameters, email addresses — none of these belong in a metric label. They belong in logs and traces, which are built for high-cardinality data.</p>



<p class="wp-block-paragraph">To see whether you already have a problem:</p>



<pre class="wp-block-code"><code># Which metric names have the most series?
topk(10, count by (__name__)({__name__=~".+"}))</code></pre>



<p class="wp-block-paragraph">Run that on any Prometheus that has been growing unexpectedly. The answer is usually one metric somebody added a request ID to.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Logs: the detail, once you know where to look</h2>



<p class="wp-block-paragraph">Logs are discrete events with arbitrary detail attached. Their strength is precision — the actual exception, the actual SQL statement, the actual value that was null. Their weakness is volume and cost, since you are storing every event rather than an aggregate.</p>



<h3 class="wp-block-heading">Structured or it did not happen</h3>



<p class="wp-block-paragraph">Compare these two lines for the same event:</p>



<pre class="wp-block-code"><code># Human readable, machine hostile
2026-01-15 14:32:11 ERROR Payment failed for user 8a3f after 3 retries

# Structured: queryable without regex archaeology
{"ts":"2026-01-15T14:32:11Z","level":"error","msg":"payment failed",
 "user_id":"8a3f","retries":3,"provider":"stripe","error_code":"card_declined",
 "trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","duration_ms":1840}</code></pre>



<p class="wp-block-paragraph">With the second, &#8220;how many payments failed with card_declined on stripe in the last hour, grouped by retry count&#8221; is a query. With the first it is a regular expression that will break the next time someone rewords the message.</p>



<h3 class="wp-block-heading">The field that ties everything together</h3>



<p class="wp-block-paragraph">Notice <code>trace_id</code> in that log line. That single field is what turns three separate tools into one workflow. Find a slow trace, take its ID, and pull every log line from every service involved in that exact request.</p>



<p class="wp-block-paragraph">If you do one thing after reading this, make it that: get trace and span IDs into your structured logs. It is usually a few lines in a logging middleware and it changes how debugging feels.</p>



<h3 class="wp-block-heading">Levels, used properly</h3>



<ul class="wp-block-list">
<li><strong>ERROR</strong> — something failed and a human may need to act. If nobody would act on it, it is not an error.</li>

<li><strong>WARN</strong> — recovered, but worth knowing. A retry succeeded, a fallback was used.</li>

<li><strong>INFO</strong> — meaningful state changes. Started, stopped, config loaded, job completed.</li>

<li><strong>DEBUG</strong> — off in production, on when you are actively investigating.</li>
</ul>



<p class="wp-block-paragraph">The failure mode is logging everything at INFO. You get volume, cost, and a signal that nobody reads because it is 99% noise. If your ERROR channel fires constantly and nobody looks, you have trained your team to ignore errors.</p>



<h3 class="wp-block-heading">Sampling, when volume gets expensive</h3>



<p class="wp-block-paragraph">Keep all errors and warnings. Sample the successful path — one in a hundred INFO lines from a healthy endpoint tells you the same thing as all hundred, at a fraction of the cost. Never sample errors. The one you drop is the one you needed.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Traces: where the time actually went</h2>



<p class="wp-block-paragraph">A trace follows one request across every service it touches. Each unit of work is a span, with a start time, duration, and a parent — so the trace becomes a tree showing exactly where the milliseconds went.</p>



<pre class="wp-block-code"><code>POST /checkout                                    1840ms
├─ auth.verify_token                                12ms
├─ cart.get_items                                   34ms
│  └─ SELECT * FROM cart_items WHERE ...            28ms
├─ inventory.check_stock                            41ms
├─ payment.charge                                 1690ms   ← here
│  ├─ HTTP POST api.stripe.com/v1/charges         1680ms
│  └─ db.insert payment_record                       8ms
└─ order.create                                     52ms</code></pre>



<p class="wp-block-paragraph">No amount of dashboard-staring gets you there that fast. The metric told you p99 checkout latency doubled; the trace tells you it is one outbound HTTP call, and now you know whether to look at your code or send an email to a vendor.</p>



<h3 class="wp-block-heading">Traces are also how you find work you did not know about</h3>



<p class="wp-block-paragraph">The classic find is the N+1 query — a trace showing four hundred nearly identical database spans where you expected one. That is invisible in metrics, because four hundred fast queries look healthy in aggregate, and invisible in logs unless you happen to log every query.</p>



<h3 class="wp-block-heading">Context propagation is the part that breaks</h3>



<p class="wp-block-paragraph">Tracing works because each service passes the trace context to the next one, usually in the W3C <code>traceparent</code> header:</p>



<pre class="wp-block-code"><code>traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             │  │                                │                │
             │  └─ trace ID                      └─ parent span   └─ flags
             └─ version</code></pre>



<p class="wp-block-paragraph">Break that chain anywhere and your trace splits in two, with the second half orphaned and useless. The usual culprits: a service that does not forward the header, a message queue where nobody propagated context into the consumer, or a thread pool that lost the context on handoff. Async boundaries are where this fails most often.</p>



<h3 class="wp-block-heading">Sampling, and why tail-based is worth the trouble</h3>



<p class="wp-block-paragraph">Tracing everything at scale is expensive, so you sample. The distinction matters:</p>



<ul class="wp-block-list">
<li><strong>Head-based</strong> — decide at the start of the request. Simple, cheap, and it throws away most of your errors, because errors are rare and the dice were rolled before anything went wrong.</li>

<li><strong>Tail-based</strong> — decide after the request finishes, when you know it was slow or failed. Keep every error, keep the slow tail, sample the boring successes at 1%.</li>
</ul>



<p class="wp-block-paragraph">Tail-based needs a collector that buffers spans until the trace completes, which costs memory and adds a component. It is almost always worth it, because a trace store full of successful requests is a trace store you never open.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Using all three: an incident, in order</h2>



<ol class="wp-block-list">
<li><strong>An alert fires on a metric.</strong> Checkout p99 latency crossed its threshold at 14:20. You know what and when.</li>

<li><strong>Check the metric&#8217;s shape.</strong> Step change or gradual climb? Correlated with a deploy, traffic spike, or nothing? Which service, which endpoint?</li>

<li><strong>Open a slow trace from that window.</strong> Not an average one — filter for the slow tail. Find the span holding the time.</li>

<li><strong>Take the trace ID into your logs.</strong> Now you are reading a handful of lines from the exact request, not grepping a firehose.</li>

<li><strong>Confirm the fix with the metric</strong> you started from. The graph coming back down is what closes the incident.</li>
</ol>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">Every step narrows the search. Metrics cover everything at low detail, traces cover one request at high detail, logs cover one moment at full detail. Going straight to logs means searching everything at full detail, which is why it feels like archaeology.</p>
</blockquote>



<h2 class="wp-block-heading">What to instrument if you are starting from nothing</h2>



<p class="wp-block-paragraph">Do not try to do all three at once. In order of value per hour spent:</p>



<ol class="wp-block-list">
<li><strong>Request rate, error rate and duration per endpoint.</strong> These three metrics answer most operational questions and take an afternoon with a middleware.</li>

<li><strong>Structured logs with a request ID.</strong> Even without tracing, a request ID threaded through your logs is transformative.</li>

<li><strong>Host metrics.</strong> CPU, memory, disk, network. Answers &#8220;is the machine underneath healthy&#8221; before you debug the application.</li>

<li><strong>Tracing on your slowest or most critical path.</strong> Not everything. Checkout, or whatever your equivalent is.</li>

<li><strong>Trace IDs in log lines,</strong> once tracing exists. This is the step that connects the tools.</li>
</ol>



<p class="wp-block-paragraph">Instrument with OpenTelemetry rather than a vendor SDK. The API is vendor-neutral, so switching backends later is a collector config change instead of touching every service. That is the single decision most likely to save you a painful migration.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Starting an investigation in logs rather than metrics.</li>

<li>Putting user IDs, request IDs or full URLs into metric labels.</li>

<li>Alerting on averages instead of percentiles.</li>

<li>Averaging percentiles across instances, which is not a meaningful operation.</li>

<li>Unstructured log messages that require regular expressions to query.</li>

<li>Head-based trace sampling, which discards most of your errors by construction.</li>

<li>Sampling error logs to save money.</li>

<li>No trace or request ID in logs, leaving the three tools unconnected.</li>

<li>Instrumenting with a vendor SDK and discovering the cost of that choice at renewal time.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Keep metric cardinality bounded and check your top series periodically.</li>

<li>Log structured JSON with consistent field names across services.</li>

<li>Propagate trace context everywhere, especially across queues and async boundaries.</li>

<li>Use tail-based sampling so errors and slow requests are always kept.</li>

<li>Set retention by usefulness: metrics for months, traces for days, logs somewhere between.</li>

<li>Alert on symptoms users feel — latency, errors, saturation — not on internal counters nobody can act on.</li>

<li>Standardise on OpenTelemetry so the backend stays replaceable.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Do I need all three?</h3>



<p class="wp-block-paragraph">Not immediately. A single service with good metrics and structured logs is well covered. Tracing earns its cost when a request crosses several services and &#8220;which one is slow&#8221; stops being obvious.</p>



<h3 class="wp-block-heading">What is the difference between monitoring and observability?</h3>



<p class="wp-block-paragraph">Monitoring watches for failure modes you predicted. Observability is being able to answer questions you did not think of in advance. Metrics and alerts are mostly monitoring; high-cardinality traces and structured logs are what make a system observable.</p>



<h3 class="wp-block-heading">Why is my Prometheus using so much memory?</h3>



<p class="wp-block-paragraph">Almost always cardinality. Memory tracks the number of active time series, not the number of hosts. Find the offending metric with a top-series query — it will usually be one where somebody added an unbounded label.</p>



<h3 class="wp-block-heading">Can I put logs into metrics or the reverse?</h3>



<p class="wp-block-paragraph">You can derive metrics from logs, and it is a reasonable stopgap when you cannot change the application. It is more expensive than emitting metrics directly and the resolution is worse. Going the other way — reconstructing event detail from metrics — is not possible, since aggregation discards it.</p>



<h3 class="wp-block-heading">How long should I keep each?</h3>



<p class="wp-block-paragraph">Metrics are cheap and useful for capacity planning, so months. Traces are large and mostly interesting while an incident is fresh, so days. Logs sit in between and are usually driven by compliance rather than debugging value.</p>



<h3 class="wp-block-heading">Is tracing worth it for a monolith?</h3>



<p class="wp-block-paragraph">Often yes, because spans still reveal internal structure — which query, which external call, how many times. The N+1 problem shows up just as clearly in a monolith as in a distributed system.</p>



<h2 class="wp-block-heading">Wrapping up</h2>



<p class="wp-block-paragraph">Metrics, logs and traces are not three ways of looking at the same data. Metrics are aggregates over time and answer whether something is wrong. Traces follow one request and answer where. Logs are individual events and answer why. Reach for them in that order and each step makes the next one smaller.</p>



<p class="wp-block-paragraph">If your setup is incomplete, the highest-value gap is usually the connective tissue rather than another data source. Trace IDs in structured logs, consistent field names, bounded metric labels. Those three make the tools you already have work together, which is worth more than adding a fourth.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Want your observability stack to actually answer questions?</h2>



<p class="wp-block-paragraph">I work with teams on observability that earns its cost — metrics with sane cardinality, structured logging that is queryable, OpenTelemetry instrumentation that survives a backend change, and alerts that fire on things people can act on.</p>



<p class="wp-block-paragraph">If your dashboards look healthy while users complain, you can hire me on Upwork.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/metrics-logs-traces/">Metrics, Logs and Traces: What Each One Answers</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
