{"id":28117,"date":"2026-05-06T08:00:00","date_gmt":"2026-05-06T06:00:00","guid":{"rendered":"https:\/\/lets-scale.it\/?p=28117"},"modified":"2026-09-16T13:28:41","modified_gmt":"2026-09-16T11:28:41","slug":"opentelemetry-rollout-start-40-services-tracing","status":"publish","type":"post","link":"https:\/\/lets-scale.it\/en\/opentelemetry-rollout-start-40-services-tracing\/","title":{"rendered":"How an OpenTelemetry Rollout Across 40 Services Begins"},"content":{"rendered":"<p>You have a landscape of several dozen services that has grown over years. Each team has its own logging, a handful of dashboards, maybe a Zipkin server somewhere that no one maintains anymore. When a request travels through five services and gets stuck somewhere, someone searches five different log aggregators for a correlation ID that doesn&#8217;t even exist in three of them. That&#8217;s exactly where the project this article is about started: 40 services, no unified tracing, and a team that knew it needed OpenTelemetry \u2014 but had no idea where to begin.<\/p>\n<p>The short answer up front: an OpenTelemetry rollout doesn&#8217;t start with dashboards. It starts with auto-instrumentation and with the decision of what you&#8217;re going to store in the first place.<\/p>\n<h2>Starting point: grown, heterogeneous, no common thread<\/h2>\n<p>The client runs around 40 services, most of them in Java (Spring Boot, some still on an old 2.x line), plus a few Node services and two Python batch jobs at the edge. Deployment via Kubernetes, roughly 30 nodes. Metrics already flow through Prometheus, logs land in a central place. Traces effectively didn&#8217;t exist \u2014 except for two services that had been manually instrumented with Jaeger clients years ago, whose SDK version nobody wanted to touch anymore.<\/p>\n<p>The client&#8217;s goal was clearly stated and about as realistic as usual: &quot;We want to see where our requests lose time.&quot; That&#8217;s a good requirement. It just says nothing about where to start when you have 40 services and a limited budget in the trace backend.<\/p>\n<h2>Why the OpenTelemetry rollout doesn&#8217;t start at the backend<\/h2>\n<p>The obvious reflex with a project like this is to pick a backend, stand up a nice dashboard, and then instrument team by team. That&#8217;s the path on which projects get expensive before they get useful.<\/p>\n<p>Traces are high-volume. A single request through five services quickly produces twenty or more spans \u2014 a span being the individual operation within a trace, such as an HTTP call or a database query. With a few thousand requests per second across the entire landscape, we&#8217;re talking about a lot of time series and spans that have to go somewhere. Dump that into the backend without sampling and you either pay for storage you never read, or the backend tips over under the load.<\/p>\n<p>That&#8217;s why the first substantive decision in the project wasn&#8217;t the choice of tool, but the question of how much we keep and of what. <strong>Trace sampling<\/strong> \u2014 the rule for which share of traces actually gets stored \u2014 is best defined before the first service sends any data. Otherwise the costs in the backend explode, and you end up retrofitting a brake onto a moving train.<\/p>\n<h2>Auto-instrumentation first, because it scales without code changes<\/h2>\n<p>With 40 services, manual instrumentation as a starting point is not an option. It would mean adding the SDK to every repository, setting spans by hand, and coordinating that over weeks with individual teams. That takes too long to build momentum.<\/p>\n<p>For the Java services we worked with the <a href=\"https:\/\/opentelemetry.io\/docs\/zero-code\/java\/agent\/\">OpenTelemetry Java Agent<\/a>. The agent attaches to the process as a JVM agent and instruments common libraries \u2014 servlet containers, JDBC, HTTP clients, messaging \u2014 without you changing a single line of application code. We tested the setup with the OpenTelemetry Java Agent 2.10, an OpenTelemetry Collector 0.115, and Grafana Tempo 2.6 as the backend.<\/p>\n<p>The agent comes into the container image as an environment-variable-driven artifact, or is supplied via an init container. The minimal configuration for a service looks like this:<\/p>\n<pre><code class=\"language-bash\"># In the container, before the java -jar call:\nexport JAVA_TOOL_OPTIONS=&quot;-javaagent:\/otel\/opentelemetry-javaagent.jar&quot;\nexport OTEL_SERVICE_NAME=&quot;checkout-api&quot;\nexport OTEL_RESOURCE_ATTRIBUTES=&quot;service.namespace=shop,deployment.environment=prod&quot;\nexport OTEL_EXPORTER_OTLP_ENDPOINT=&quot;http:\/\/otel-collector.observability:4317&quot;\nexport OTEL_EXPORTER_OTLP_PROTOCOL=&quot;grpc&quot;\nexport OTEL_TRACES_SAMPLER=&quot;parentbased_traceidratio&quot;\nexport OTEL_TRACES_SAMPLER_ARG=&quot;0.1&quot;<\/code><\/pre>\n<p>Three things matter here. <code>OTEL_SERVICE_NAME<\/code> is the name the service appears under in the backend \u2014 that becomes relevant later. <code>service.namespace<\/code> and <code>deployment.environment<\/code> as resource attributes let you separate prod from staging and group services. And the sampler: <code>parentbased_traceidratio<\/code> with argument <code>0.1<\/code> means that a service starting a trace keeps it in 10 percent of cases \u2014 and that a service continuing an already-sampled trace inherits the parent span&#8217;s decision. This is crucial so a trace doesn&#8217;t break off midway because a downstream service rolls the dice differently.<\/p>\n<h2>Sampling: coarse head-based first, then targeted<\/h2>\n<p>Head-based sampling \u2014 the decision is made at the start of the trace \u2014 has one drawback: at the moment of the decision you don&#8217;t yet know whether the request will end in an error. But those are exactly the traces you want to see: the ones with errors and high latency. That&#8217;s why the 10 percent rule at the SDK was only set as a first, cheap filter to cap the baseline load.<\/p>\n<p>The actual intelligence went into the collector, via the <a href=\"https:\/\/github.com\/open-telemetry\/opentelemetry-collector-contrib\/tree\/main\/processor\/tailsamplingprocessor\">tail sampling processor<\/a>. Tail-based sampling \u2014 the decision is made after the complete trace has been collected \u2014 lets you deliberately keep all erroneous and slow traces and only a sample of the rest:<\/p>\n<pre><code class=\"language-yaml\">processors:\n  tail_sampling:\n    decision_wait: 10s\n    num_traces: 50000\n    policies:\n      - name: errors\n        type: status_code\n        status_code:\n          status_codes: [ERROR]\n      - name: slow-requests\n        type: latency\n        latency:\n          threshold_ms: 800\n      - name: baseline-sample\n        type: probabilistic\n        probabilistic:\n          sampling_percentage: 10\n\nexporters:\n  otlp\/tempo:\n    endpoint: tempo-distributor.observability:4317\n    tls:\n      insecure: true\n\nservice:\n  pipelines:\n    traces:\n      receivers: [otlp]\n      processors: [tail_sampling, batch]\n      exporters: [otlp\/tempo]<\/code><\/pre>\n<p>The catch with tail-based sampling that you need to know: the collector has to buffer all spans of a trace until it can decide. That costs memory and only works cleanly if all spans of a trace arrive at the same collector process. In a horizontally scaled collector fleet you need a layer that routes by <code>trace_id<\/code> \u2014 otherwise each collector only sees part of the trace and decides incorrectly. We solved this with a two-stage collector topology: agent collectors gather per node, and a small gateway layer with the <code>loadbalancing<\/code> exporter distributes by <code>trace_id<\/code> to the sampling collectors. This is the part that&#8217;s easily underestimated on a first rollout.<\/p>\n<pre><code class=\"language-mermaid\">flowchart LR\n    A[Service + Java Agent] --&gt;|OTLP head 10%| B[Collector Agent pro Node]\n    B --&gt;|loadbalancing nach trace_id| C[Collector Gateway]\n    C --&gt;|tail_sampling| D[Tempo]<\/code><\/pre>\n<h2>Why the developers initially ignored the traces<\/h2>\n<p>After two sprints, half the landscape was instrumented, traces were flowing into the backend, and the sampling costs were under control. Then something happened in the retro that we&#8217;ve seen in similar form across several projects: the developers weren&#8217;t using the traces.<\/p>\n<p>Not because they were unusable. But because the span names were cryptic. Auto-instrumentation names spans generically \u2014 an HTTP server span ends up being something like <code>GET<\/code> or, if you&#8217;re lucky, <code>GET \/api\/v1\/{id}<\/code>, a JDBC span becomes <code>SELECT shopdb<\/code>. When a developer opens a trace and sees a list of twenty rows of <code>GET<\/code>, <code>SELECT<\/code>, <code>HTTP POST<\/code> with no business context, they close the window again and go back to the log.<\/p>\n<p>This is the real lesson from the project, and it&#8217;s not a lesson about technology: <strong>observability rarely fails on the technology; it usually fails on the naming.<\/strong> You can build the cleanest pipeline \u2014 if no one can tell from the trace what&#8217;s happening in business terms, the tool won&#8217;t get used.<\/p>\n<h2>A naming convention you can actually keep up<\/h2>\n<p>The solution was unspectacular, and precisely for that reason effective. We defined a short convention and enforced it where it pays off most: at the entry points a developer sees first.<\/p>\n<p>First: service names, business-oriented and consistent. No <code>svc-2<\/code>, no <code>spring-app<\/code>, but <code>checkout-api<\/code>, <code>payment-worker<\/code>, <code>inventory-sync<\/code>. That only costs the discipline of setting <code>OTEL_SERVICE_NAME<\/code> cleanly.<\/p>\n<p>Second: server spans with the route template instead of the resolved URL. The Java Agent usually does this automatically for Spring, so a thousand variants with different IDs collapse into one span <code>GET \/orders\/{orderId}<\/code> \u2014 which is at the same time the most important lever against a cardinality explosion, that is, against a proliferation of distinguishable span names that bloat the backend.<\/p>\n<h2>Manual spans only at the business-critical points<\/h2>\n<p>The third building block is manual spans with meaningful names at the business-critical points. This is the only place where we touched code, and we deliberately kept it small:<\/p>\n<pre><code class=\"language-java\">import io.opentelemetry.instrumentation.annotations.WithSpan;\nimport io.opentelemetry.instrumentation.annotations.SpanAttribute;\n\npublic class OrderService {\n\n    @WithSpan(&quot;order.validate&quot;)\n    public ValidationResult validate(@SpanAttribute(&quot;order.id&quot;) String orderId) {\n        \/\/ business logic\n    }\n}<\/code><\/pre>\n<p>The <code>@WithSpan<\/code> annotation from <code>opentelemetry-instrumentation-annotations<\/code> creates a span with the given name, <code>@SpanAttribute<\/code> attaches a business value. This puts <code>order.validate<\/code> in the trace instead of an anonymous method signature. A developer investigating a slow checkout sees at a glance whether validation or payment is eating the time.<\/p>\n<p>Since this convention, usage rose measurably \u2014 in the sense that teams opened Tempo on their own to investigate incidents, instead of jumping into the log first. We deliberately did not collect hard before-and-after adoption figures, because they&#8217;re easy to fudge. The qualitative signal was unambiguous enough.<\/p>\n<h2>What changed after the first weeks<\/h2>\n<p>After the first weeks, we had a pipeline that covered the bulk of the Java services, kept the backend load within a predictable range through two-stage sampling, and produced traces that were readable in business terms. The 10 percent baseline rate plus the targeted retention of errors and slow requests reduced the volume in the backend significantly compared to an unfiltered variant \u2014 concretely by more than an order of magnitude, without losing the diagnostically valuable traces.<\/p>\n<p>More important than the number: the order was right. First auto-instrumentation for breadth, then sampling for cost control, then naming for usability. Dashboards came last \u2014 and were then simple, because the data sat cleanly named in Tempo.<\/p>\n<h2>Limits of this approach<\/h2>\n<p>Auto-instrumentation covers common libraries, not your business logic. Everything that happens inside a service between two framework calls stays a black box until you set spans manually. Anyone who believes the agent alone gives them full visibility will be disappointed.<\/p>\n<p>Head-based sampling with a fixed ratio is coarse. On a rarely used service with very low traffic, 10 percent may leave you without a single trace for days. For such services a higher rate, or a per-service rate, is worth it \u2014 which complicates the configuration.<\/p>\n<p>Tail-based sampling is the most expensive part to operate. It needs memory, it needs the trace_id-based distribution, and it introduces latency through <code>decision_wait<\/code>. In smaller landscapes with manageable volume, head-based sampling alone can be entirely sufficient; then you save yourself the gateway layer. You shouldn&#8217;t introduce tail-based sampling on principle, but when the volume forces it.<\/p>\n<p>And for languages beyond Java, the comfort is lower. Node still auto-instruments well; with other runtimes more manual work is required. A unified rollout doesn&#8217;t mean the same effort everywhere.<\/p>\n<h2>The takeaway: the order decides, not the dashboard<\/h2>\n<p>A tracing rollout doesn&#8217;t start with the pretty dashboard, but with two sober decisions: what you auto-instrument and what you keep. And it stands or falls on the naming \u2014 the best pipeline is useless if no one can tell from the trace what&#8217;s happening in business terms.<\/p>\n<p>We accompany rollouts like this from the first sampling decision to the point where the traces arrive in the teams&#8217; daily work. When your landscape has grown and unified tracing is missing, the structured start is usually the harder part than the technology itself.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>How an OpenTelemetry rollout starts in a grown service landscape: auto-instrumentation, sampling rules, and span names developers actually use.<\/p>\n","protected":false},"author":6,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[27,1],"tags":[],"class_list":["post-28117","post","type-post","status-publish","format-standard","hentry","category-it-monitoring-en","category-uncategorized"],"_links":{"self":[{"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/posts\/28117","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/users\/6"}],"replies":[{"embeddable":true,"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/comments?post=28117"}],"version-history":[{"count":4,"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/posts\/28117\/revisions"}],"predecessor-version":[{"id":28146,"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/posts\/28117\/revisions\/28146"}],"wp:attachment":[{"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/media?parent=28117"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/categories?post=28117"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/lets-scale.it\/en\/wp-json\/wp\/v2\/tags?post=28117"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}