OpenTelemetry Collector Custom Builds: 6.5x Smaller, and What Resource Detectors Cost at Startup
---
title: OpenTelemetry Collector Custom Builds: 6.5x Smaller, and What Resource Detectors Cost at Startup
published: true
date: 2026-11-05 15:00:00 UTC
tags: DevOps,OpenTelemetry,Observability,Go
canonical_url: devops-daily.com
---
The contrib release of the OpenTelemetry Collector 0.164.0 is a 420.3 MB binary on linux/amd64. We built a Collector with the OpenTelemetry Collector Builder (ocb) that has only the 10 components a VM agent uses and only the env and system resource detectors. It is 64.3 MB, so contrib is 6.5 times larger. After 10 seconds idle, the custom build held 45.1 MiB of resident memory and contrib held 199.8 MiB. The anonymous part (heap, stacks and runtime data) was 8.0 MiB against 34.8 MiB.
The binary adds a little to startup: contrib needed 110 to 160 ms more than the small build on the droplet, and 300 to 500 ms more on the Pi. The detector list in your config can add seconds. We started both builds with cloud detectors for clouds the host was not on, in four network setups. In every list without akamai, each start waited for the slowest detector. When that detector got no answer, it waited for the whole timeout, which is 5 s by default. That happened on a Raspberry Pi with no metadata service. With a list of 13 cloud detectors it also happened on a DigitalOcean droplet, because two of them ask addresses that DigitalOcean does not answer. The detectors run in parallel, so where nothing answered, the list with 3 cloud detectors and the list with 13 cost the same 5 s.
timeout: 1s cut the wait to about 1 s in most cases. Two detectors do not obey it. digitalocean ignores the deadline, so where nothing answers it waits its own 2 s. akamai makes its network call while the processor is created. In all four setups we tested, none of them on Akamai, the Collector did not start. It exited at once where the request got an answer or a fast error, and after 30 s where it got no answer.
On 29 October the OpenTelemetry blog showed how to compile detectors out with ocb and reported a Kubernetes distribution about 20% smaller. This post measures three things that page does not cover. What does the same filter do to a small build? What does the build do to memory at idle? And what does the detector list cost at startup on hosts that are not on the clouds the config names? Every number below comes from a file in the companion repo.
TL;DR
- Size. On amd64, contrib is 420.3 MB, the core otelcol release is 183.8 MB, our agent with every detector is 124.1 MB, and the same agent with only env and system is 64.3 MB. On this small build, the detector filter alone removed 48% of the binary and 64 Go modules: the AWS SDK, the Google Cloud clients, the Docker client, the Consul API and four more cloud metadata clients. arm64 gives the same picture.
- Memory. After 10 s idle: contrib 199.8 MiB RSS, core 98.4 MiB, our agent 61.5 MiB, the agent without cloud detectors 45.1 MiB. About four fifths of each number is file-backed memory (RssFile), which grows with the binary. The anonymous part (RssAnon) was 34.8 MiB for contrib and 8.0 MiB for the small build.
- Startup with no cloud detectors: about 190 to 220 ms for contrib and 45 to 85 ms for the small agents on the droplet, and 480 to 610 ms against 110 to 270 ms on the Pi.
- Startup with cloud detectors for other clouds: [env, system, ec2, gcp, azure] held startup for the full 5 s timeout where 169.254.169.254 got no answer. Where the connect failed at once, it held startup for 4.1 s, because the AWS SDK retries the connect. On the droplet those three cost under 4 ms each, but the 13-cloud list still took 5.1 s there, because alibaba_ecs and scaleway ask other addresses.
- The timeout setting bounds the wait: 1 s, 5 s or 10 s plus normal startup. The exceptions are digitalocean, which waits its own 2 s whatever you set when nothing answers, and akamai.
- No start at all. akamai in the list: exit 1 in all four setups, after 30 s in the two with no answer. fail_on_missing_metadata: true: exit 1 after the 5 s timeout. A detector that ocb compiled out but the config still names: validate passes, then a nil pointer panic at start. The processor's name for a detector in the ocb manifest (ec2 instead of aws_ec2): the build stops.
- What to do. List only the detectors for the platform the host runs on, and build with the same list in dist.resource.detection.detectors. If one config must cover several clouds, keep the list short, set timeout to 1 or 2 s, and leave out akamai.
Prerequisites
- An x86_64 Linux host for the builds, with about 3 GB of free disk. ocb cross-compiles the arm64 binaries there.
- curl, python3 and ip (iproute2). Root only for the two network namespaces.
- An arm64 Linux host if you want the second architecture. We used a Raspberry Pi with 4 cores and less than 4 GB of memory.
- About an hour for a full run. The cases that fail after 30 s take most of it.
What we built and where we ran it
We downloaded the official 0.164.0 releases of otelcol-contrib and otelcol for both architectures and checked each tarball against its published .sha256. Then ocb 0.164.0 built two versions of one small agent. It has the components a VM agent uses:
- receivers: OTLP, hostmetrics and filelog
- processors: batch, memory_limiter and resource_detection
- exporters: debug, OTLP and OTLP/HTTP
- extension: health_check
otelcol-agent uses the ocb default, so every detector is compiled in. otelcol-agent-envsys adds the ocb setting that compiles out every detector you do not list:
# manifests/agent-envsys.yaml (excerpt, comment on detectors added)
dist:
name: otelcol-agent-envsys
version: 0.164.0
build_tags: 'grpcnotrace' # same tag as the contrib release manifest
resource:
detection:
detectors: # ocb 0.163.1 and later: every detector not listed gets an omit_detector_<name> build tag
- env:
- system:
go version -m on the release binaries reports go1.26.10, so we pinned the same Go for our builds. go version -m and file show -trimpath=true, CGO_ENABLED=0 and stripped symbols for every binary (results/buildinfo/).
Every start ran the same pipeline: OTLP in, batch, debug out. The harness gave each start a clean environment with only PATH and an empty HOME. No OTEL_* or AWS_* variable and no ~/.aws file could change what a detector found. The Pi runs also set TZ=UTC, so their logs use UTC like the droplet's; the droplet runs used an earlier revision of the harness without it. Only the resource_detection block changed between configs. Each case ran 5 times in a shuffled order, after one warm-up start per binary that we left out of the numbers.
- DigitalOcean droplet 4 vCPU, 8 GB, amd64
- host network metadata server answers
- blackhole namespace packets dropped
- noroute namespace connect fails at once
- Raspberry Pi 4 cores, under 4 GB, arm64
- home network router drops 169.254.169.254
The four network setups matter more than the two architectures:
| Setup | What a request to 169.254.169.254 gets |
| --- | --- |
| Droplet host | DigitalOcean's metadata server answers. The EC2 and Azure paths return 404 in about 1 ms. This is the "host on another cloud" case. |
| Blackhole namespace | The default route goes to a dummy device, which discards every packet, so a connect gets no answer. DNS fails at once, because nothing listens on 127.0.0.53 inside the namespace. |
| Noroute namespace | Loopback only. A connect fails at once with "network is unreachable". |
| Raspberry Pi | The home router drops the packets. DNS works. This is the plain "not on any cloud" host. |
The-DevOps-Daily/otelcol-ocb-vs-contrib on GitHub
Size: what ocb removes
Binary size, linux/amd64, OpenTelemetry Collector 0.164.0
| | Value | Series |
| --- | --- | --- |
| otelcol-contrib (release) | 420.3 MB | release |
| otelcol core (release) | 183.8 MB | release |
| agent, all detectors | 124.1 MB | custom |
| agent, env + system only | 64.3 MB | custom |
_results/sizes.tsv. Stripped, -trimpath, CGO_ENABLED=0, go1.26.10. The two agents have the same 10 components; only the compiled-in detectors differ._
The contrib manifest lists 246 components and 9 config providers, and the binary links 1,088 Go modules. The core release is not small either. Its 30 components include the Kafka and Prometheus receivers and exporters, and it also ships resource_detection with every detector. Our agent with every detector links 289 modules, and the detector filter takes it to 225.
The filter saves more on a small build than on a big one. The OpenTelemetry blog measured the Kubernetes distribution and got 182 MB to 147 MB with 13 fewer dependencies. On our agent it removed 59.8 MB of 124.1 MB (48%) on amd64, 52.9 MB of 112.7 MB (47%) on arm64, and 64 modules. A big distribution needs most of those SDKs for other components, so removing the detectors does not remove the SDKs. In a small agent, nothing else needs them. results/deps/removed-by-detector-filter-amd64.txt lists the 64: 15 aws-sdk-go-v2 and smithy-go modules, the Google Cloud compute, auth and API clients, the Docker (moby) client, the Consul API with its HashiCorp libraries, and the Hetzner, Scaleway, Linode and DigitalOcean clients.
For containers, the official otel/opentelemetry-collector-contrib:0.164.0 image is 116.1 MB compressed on amd64 (results/images.tsv). We did not build an image of the small agent. Its binary compresses to 20.3 MB with gzip -6, so a FROM scratch image would be near that, but that is an estimate, not a measurement.
One build difference favours our side. The contrib manifest asks for the grpcnotrace build tag and our builds use it, but the release binaries record no build tags in go version -m. We built otelcol-agent-notag to put a size on it: 124.23 MB against 124.11 MB, so the tag is worth 0.1%.
Memory at idle
Resident memory after 10 s idle, droplet, amd64
| | Value | Series |
| --- | --- | --- |
| contrib: RssAnon | 34.8 MiB | RssAnon |
| contrib: RssFile | 165.2 MiB | RssFile |
| core: RssAnon | 18.7 MiB | RssAnon |
| core: RssFile | 79.5 MiB | RssFile |
| agent, all detectors: RssAnon | 11.2 MiB | RssAnon |
| agent, all detectors: RssFile | 50.2 MiB | RssFile |
| agent, env + system: RssAnon | 8 MiB | RssAnon |
| agent, env + system: RssFile | 37 MiB | RssFile |
_Median of 5 starts, /proc//status, config without resource_detection, results/summary.md. RssFile is resident file-backed memory; RssAnon is resident anonymous memory (heap, stacks, runtime)._
Resident memory follows the binary size, but not one to one. Contrib held 199.8 MiB, 4.4 times the 45.1 MiB of the small agent. About four fifths of every number is RssFile, resident memory backed by files. It grows with the binary: 165.2 MiB for contrib, 37.0 MiB for the small agent. That points at the binary's own pages, but we did not map them one by one. The kernel can drop clean file-backed pages under memory pressure and read them again later. RssAnon is anonymous memory, which it cannot drop without swap. There contrib held 34.8 MiB against 8.0 MiB. A Go program runs the init code of every package it links before main, so a likely part of that difference is setup work for code that this pipeline never uses. We did not profile the heap to prove it.
Detection with [env, system] added little: contrib went to 205.4 MiB and the small agent to 47.8 MiB. The Raspberry Pi gave the same order on arm64: contrib 183.8 MiB with 34.7 MiB anonymous, the small agent 40.6 MiB with 8.0 MiB.
We did not test what happens to RssFile under memory pressure, or how a cgroup memory limit counts it. Read the RssAnon column as the floor and VmRSS as what top shows you.
Startup: the slowest detector sets the time
Median time from exec to the log line "Everything is ready", 5 starts each, from results/summary.md. Each cell is the small agent with every detector, then contrib in brackets:
| Config | Droplet host | Blackhole namespace | Noroute namespace | Raspberry Pi |
| --- | --- | --- | --- | --- |
| [env, system] | 79 ms (189 ms) | 77 ms (210 ms) | 83 ms (221 ms) | 192 ms (481 ms) |
| + ec2, gcp, azure | 81 ms (196 ms) | 5,088 ms (5,203 ms) | 4,098 ms (4,242 ms) | 5,172 ms (5,486 ms) |
| + 13 VM clouds, no akamai | 5,090 ms (5,213 ms) | 5,086 ms (5,203 ms) | 4,087 ms (4,227 ms) | 5,167 ms (5,393 ms) |
| + akamai | exit 1 after 0.1 s (0.2 s) | exit 1 after 30.1 s (30.2 s) | exit 1 after 0.1 s (0.2 s) | exit 1 after 30.2 s (30.5 s) |
Without a network wait, contrib needs about 110 to 160 ms more than the small agent to start on the droplet, and about 300 to 500 ms more on the Pi. That is real but small, and it matters only if you start collectors very often, for example one per short CI job.
The wait comes from three things in the processor source (processor/resourcedetectionprocessor at v0.164.1):
1. Detectors run in parallel. detectResource starts one goroutine per detector and then reads their results in order (internal/resourcedetection.go, lines 203 to 222). The wait is the slowest detector, not the sum.
2. The whole detection has one deadline: timeout. The default is 5 s (factory.go, lines 153 to 157). With the default retry.max_elapsed_time: 0, Refresh puts that timeout on the context that every detector gets (internal/resourcedetection.go, lines 159 to 168).
3. Startup waits for it. The processor's Start runs the first detection and returns its error (resourcedetection_processor.go, lines 36 to 40). The Collector logs "Everything is ready" only after every processor has started.
The processor also records how long each detector took, in its own otelcol_resourcedetection_detector_duration histogram. We read it from the Collector's Prometheus endpoint after each start. This is the 13-cloud list in the blackhole namespace:
Time per detector, 13 VM cloud detectors, blackhole namespace, timeout 5s
| | Value | Series |
| --- | --- | --- |
| ec2 | 5000.7 ms | waits for timeout |
| alibaba_ecs | 4999.8 ms | waits for timeout |
| azure | 4999.7 ms | waits for timeout |
| hetzner | 5000.2 ms | waits for timeout |
| scaleway | 4999.6 ms | waits for timeout |
| digitalocean | 2000.6 ms | own 2 s timeout |
| nova | 2000.4 ms | own 2 s timeout |
| upcloud | 2000.7 ms | own 2 s timeout |
| vultr | 2000.8 ms | own 2 s timeout |
| oraclecloud | 200.7 ms | fast |
| ibmcloud_vpc | 1.2 ms | fast |
| gcp | 0.9 ms | fast |
| tencent_cvm | 0.7 ms | fast |
_Median of 5 starts of the small agent with all detectors, from otelcol_resourcedetection_detector_duration in results/runs/droplet-blackhole-extra.jsonl. Every detector reported outcome=success: "not on this cloud" is a success with no attributes._
The same histogram tells you which detector held each of the other setups:
| Setup | Detectors that took the longest |
| --- | --- |
| Droplet host | alibaba_ecs and scaleway, 5.0 s each. They ask 100.100.100.200 and 169.254.42.42, and nothing on DigitalOcean answers those. The other 11 finished in 2 to 20 ms (medians). |
| Blackhole namespace | Five detectors at 5.0 s and four at 2.0 s (see the chart). |
| Noroute namespace | ec2 alone, 4.0 s. Every other detector finished in under 2 ms. |
| Raspberry Pi | Six detectors at 5.0 s: the five from the blackhole namespace and ibmcloud_vpc, which took 1.2 ms in the namespace. We did not trace why. The same four at 2.0 s. With working DNS, gcp took 13.5 ms and tencent_cvm 15.9 ms. |
How each detector probes decides how long it waits:
- azure sends one GET with a plain &http.Client{} that has no timeout (internal/metadataproviders/azure/metadata.go, lines 33 to 38). When the packets get no answer, it waits for the processor's deadline. Then it returns an empty resource and no error (internal/azure/azure.go, lines 55 to 63).
- ec2 uses the AWS SDK's IMDS client: a 250 ms dial timeout, a 500 ms response header timeout (aws-sdk-go-v2 feature/ec2/imds v1.20.1, api_client.go, lines 277 and 278), and up to 3 attempts per request (internal/aws/ec2/config.go, lines 16 to 19). A connect that fails at once is retried with backoff, which is why ec2 took 4.0 s in the noroute namespace. With no answer at all and a 10 s timeout, it gave up by itself after 5.5 s.
- gcp calls metadata.OnGCEWithContext, which races a DNS lookup of metadata.google.internal against an HTTP probe. On a host that does not look like a Google VM, it trusts the first answer (cloud.google.com/go/compute/metadata v0.10.0, metadata.go, lines 456 to 480). A fast NXDOMAIN or a fast DNS failure ends it.
- digitalocean ignores the context: its Detect takes _ context.Context (internal/digitalocean/digitalocean.go, line 51), and the DigitalOcean metadata client has a fixed 2 s timeout (digitalocean/go-metadata, client.go, lines 25 and 64).
We started from this claim: on a host that is not on EC2, Google Cloud or Azure, each cloud detector adds a wait at startup. Two results would prove it wrong: no wait at all, or the same wait for 3 detectors and for 13. We got the second, so it was half wrong. The detectors do not each add a wait. During detection they overlap, and the slowest one sets the time. But a host on a cloud with a metadata server is not safe either, because not every cloud uses 169.254.169.254.
The timeout setting
Start to "Everything is ready" with [env, system, ec2, gcp, azure]
| | timeout: 1s | timeout: 5s (default) | timeout: 10s |
| --- | --- | --- | --- |
| contrib, blackhole namespace | 1238.8 ms | 5203.1 ms | 10235.4 ms |
| agent, blackhole namespace | 1077.1 ms | 5087.6 ms | 10085.4 ms |
| contrib, Raspberry Pi | 1526.7 ms | 5485.5 ms | 10710.8 ms |
| contrib, noroute namespace | 1240.1 ms | 4241.6 ms | 4229.5 ms |
| contrib, droplet host | 209.3 ms | 196 ms | 216 ms |
_Median of 5 starts per point, results/summary.md. The droplet host answers on 169.254.169.254; the blackhole namespace and the Pi do not. The noroute namespace fails the connect at once._
Where nothing answers, the wait is the timeout plus normal startup, on both architectures. In the noroute namespace it stops at 4.1 to 4.2 s, where ec2 gives up. timeout: 1s brought every three-cloud case that waited down to 1.1 to 1.2 s.
The 13-cloud list with timeout: 1s did not get there. In the blackhole namespace it started in 2,097 ms (contrib 2,234 ms), and on the Pi in 2,175 ms (contrib 2,410 ms), because digitalocean took its 2 s anyway.
A short timeout has a cost that we could not measure. On a real EC2, Google Cloud or Azure VM, a detector that needs more than 1 s gets cut off. It returns an empty resource with no error, and your telemetry has no cloud attributes. We did not run on those clouds, so we cannot say whether 1 s is enough there. Most examples in the processor README use timeout: 2s.
Four ways the Collector does not start
1. ocb build unknown detector name: stops
2. otelcol validate passes a compiled-out detector
3. create processor akamai fetches a token
4. Start: detection parallel, one deadline
5. Everything is ready
1. The akamai detector
Of the 14 VM cloud detectors we ran, akamai is the only one that waited or failed while the processor was created. Its NewDetector calls newAkamaiClient(context.Background()) (internal/akamai/akamai.go, lines 43 to 57), and the Linode metadata client fetches a token inside NewClient (linode/go-metadata v0.3.1, client.go, lines 88 to 91). This happens before detection, so timeout does not apply, and a failure there is a failure to build the pipeline.
On the droplet, DigitalOcean's metadata server answers the token request, but not with a token, and the Collector exits at once:
results/runs/droplet-host-extra.jsonl, otelcol-contrib, configs/akamai.yaml
$ bin/linux-amd64/otelcol-contrib --config configs/akamai.yaml
Error: failed to build pipelines: failed to create "resource_detection" processor, in pipeline "traces": failed creating detector type "akamai": failed to refresh metadata token: failed to generate metadata token: no token returned from API
# exit code 1, 0.2 s after exec
In the noroute namespace it exited at once with connect: network is unreachable. In the blackhole namespace and on the Pi, the token request first waited for a 30 s dial timeout. The Collector exited after 30.1 s in the namespace and 30.2 s on the Pi (contrib: 30.2 s and 30.5 s). With timeout: 1s it still took 30 s.
2. fail_on_missing_metadata: true
This setting makes a cloud detector return an error when its metadata service is missing, instead of an empty resource. Retry is on by default (factory.go, lines 142 to 151), so the processor retries the detector with backoff until timeout, and then Start returns the error. With [env, system, ec2, gcp, azure], the Collector exited with code 1 after 5.1 to 5.3 s in all three droplet setups. The config comment says the setting is for nodes that boot before their metadata service is ready (config.go, lines 63 to 70). It also turns "not on EC2" into "does not start".
3. A detector that ocb compiled out
This is the trap in the new ocb feature. otelcol-agent-envsys has only env and system. We gave it the three-cloud config:
results/break/amd64-validate.txt and results/break/amd64-start.txt
$ bin/linux-amd64/otelcol-agent-envsys validate --config configs/clouds3.yaml
exit code: 0
$ bin/linux-amd64/otelcol-agent-envsys --config configs/clouds3.yaml
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x18 pc=0x130a757]
goroutine 57 [running]:
github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal.(*ResourceProvider).detectWithRetry.func2()
github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor@v0.164.1/internal/resourcedetection.go:314 +0xb7
...
exit code: 2
In v0.164.1 each *_omit.go stub returns nil, nil from NewDetector, for example internal/aws/ec2/ec2_omit.go. The processor keeps the nil detector and calls it. This is issue #52519, open when we wrote this. On main, #52063 changes the stubs so they register no factory. It was merged after v0.164.1. With it, the processor returns detector "ec2" is not compiled into this binary with the list of compiled detectors. Until a release has that change, validate does not catch the mismatch, and the first sign is a crash.
4. The processor's names in the ocb manifest
ocb and the processor do not use the same detector names. The processor config says ec2, nova and aks. The ocb manifest wants the build tag names aws_ec2, openstack_nova and azure_aks (cmd/builder/internal/builder/config.go, lines 117 to 151). This one fails early, which is the good kind of failure:
results/build/agent-badname-amd64.log
$ ocb --config manifests/agent-badname.yaml
Error: invalid configuration: ec2: unknown resource detector
What to put in the manifest and the config
Make the two lists match, each in its own names:
# ocb manifest: build tag names
dist:
resource:
detection:
detectors:
- env:
- system:
- aws_ec2: # the processor calls this one "ec2"
# Collector config: processor names, the same detectors
processors:
resource_detection:
detectors: [env, system, ec2]
timeout: 2s # the most a missing metadata service can add to startup
- Use one list per platform. A fleet on EC2 does not need azure, and a fleet on bare metal needs neither.
- If one config must cover several clouds, keep the list short and set timeout to 1 or 2 s. Expect digitalocean to add 2 s, whatever the timeout, on hosts where its request gets no answer.
- Leave akamai out unless the hosts run on Akamai Cloud.
- Keep fail_on_missing_metadata off unless "no metadata" should mean "do not start".
- Use the name resource_detection. In 0.164 the old name resourcedetection still works, with a deprecation warning (results/break/amd64-legacy-name.txt).
- Until the #52063 change is in a release, start each new custom build once with its real config before you ship it. validate is not enough.
What we could not conclude
- On a real AWS, Google Cloud or Azure VM. We had no instances there, so we did not measure how long a detector takes when it succeeds, or whether a 1 s timeout cuts it off.
- The container image of the small agent. We report the compressed size of the official image and the gzip size of our binary. We did not build or push an image.
- Memory under pressure. RssFile can be reclaimed. We did not measure how much the kernel takes back, or how a cgroup limit counts it.
- Small startup differences. Each case has 5 starts. The 110 to 160 ms between contrib and the small agent repeats in every droplet setup. The Pi also ran other work, and three of its starts took 4.6 to 22 s. In all three, the time went before the Collector wrote its first log line, which points at reading the binary from the SD card, not at detection. We did not confirm that. They move the medians little, but they are in the min and max columns of results/summary.md.
- Slow DNS. DNS failed at once in the namespaces and answered on the Pi, so gcp and tencent_cvm were fast everywhere. A host whose DNS times out would make them wait too. We did not test that.
- Other versions. Everything here is 0.164.0 and 0.164.1. The akamai and omit behaviour may change in the next release.
Related reading:
- Distributed tracing with OpenTelemetry runs a Collector end to end.
- OpenTelemetry graduated covers which vendor agents a Collector can replace.
- Trace context in environment variables follows a trace through a CI pipeline.
Summary
A custom Collector with only the components you use is 6.5 times smaller than contrib and holds under a quarter of its memory. On a small build, compiling out the unused detectors is half of the size saving. The binary costs tens to hundreds of milliseconds at startup. The detector list can cost seconds. Apart from akamai, the cloud detectors you list for clouds the host is not on share one wait: the slowest probe. Where that probe gets no answer, it usually runs to the full timeout, even on a host on another cloud. digitalocean waits its own 2 s instead, even past a shorter timeout. Keep the list to the platforms you run on, and build with the same list. Set a short timeout if the list must stay long. Start each new binary once with its real config. Leave akamai out unless you run on Akamai.
_Originally published at devops-daily.com._
Rust Observability in 2026: OTLP Logs and Metrics Are Stable—What About Traces?
---
title: "Rust Observability in 2026: OTLP Logs and Metrics Are Stable—What About Traces?"
published: true
description: "OpenTelemetry Rust 0.33.1 stabilizes OTLP log and metric exporters. Here's what that means—and why tracing is still beta."
tags: rust, opentelemetry, observability, devops
---
Rust has a strong observability ecosystem built around tracing, but getting its events into an observability backend involves a few distinct pieces: spans, log records, metrics, and exporters.
There is a useful milestone in the current OpenTelemetry Rust release that makes this setup more interesting: as of OpenTelemetry Rust 0.33.1, the OTLP exporters for logs and metrics are stable, while the OTLP exporter for traces remains in beta.
That distinction is easy to miss if we just say “OpenTelemetry is stable.” Let’s look at what changed and how it maps to a real Rust service.
What became stable?
The OpenTelemetry Rust 0.33.1 release, published on October 8, 2026, declared the OTLP exporters for Logs and Metrics stable after a release-candidate period. The Traces exporter remains beta.
| Signal | OTLP exporter status in 0.33.1 |
| --- | --- |
| Logs | Stable |
| Metrics | Stable |
| Traces | Beta |
This is specifically about the OTLP exporters in the Rust implementation—not a blanket statement that every OpenTelemetry crate or API has the same stability guarantee. The release notes also describe the Logs and Metrics APIs and SDK as stable, while Distributed Tracing remains pre-stable.
There is another useful reliability improvement: starting in 0.33.0, OTLP retries are enabled by default for HTTP and gRPC exporters. The default policy uses exponential backoff and jitter, with up to three retries after the first attempt. In other words, a temporary collector hiccup no longer necessarily means an export is immediately lost.
tracing events can become either logs or trace data
One source of confusion is that two similarly named integrations do different jobs:
- tracing-opentelemetry connects Rust tracing spans and events to OpenTelemetry traces.
- opentelemetry-appender-tracing forwards tracing events to the OpenTelemetry Logs pipeline.
Both can be installed as layers on a tracing_subscriber. A log record and a span event are related, but they are not the same telemetry signal; the bridge you choose determines where the event goes.
In our service setup, the key pieces look like this:
let trace_layer = tracing_opentelemetry::layer().with_tracer(tracer);
let log_layer = OpenTelemetryTracingBridge::new(&logger_provider);
The trace provider and logger provider each receive exporters. The metrics provider is separate again: it creates instruments and collects measurements on its configured interval. All three can share the same OpenTelemetry Resource, so telemetry is attributed to the same service name and version.
A small configuration example
The AICC crate wraps provider and subscriber setup behind a builder-style API. With a local OpenTelemetry Collector listening on gRPC port 4317, initialization is just a few lines:
use aicc::o11y::prelude::*;
use tracing::info;
let options = TracingOptions::default()
.with_service("orders-api", "1.2.0")
.with_otlp_exporter(OtlpExporterOptions::new("http://localhost:4317"));
let _guard = init_tracing(&options)?;
info!(order_id = 42, "Order accepted");
The exporter options enable all three signals by default. In the implementation, separate builders create the OTLP span, metric, and log exporters, and the resulting providers are attached to the subscriber or registered as global providers where needed.
The returned guard matters. Keep it alive for the lifetime of the application: when it is dropped, it shuts down the providers and flushes pending telemetry. If you create a provider manually, make sure your shutdown path does the equivalent.
A practical detail: exporters can produce telemetry too
The OTLP exporter and its transport stack can emit tracing events of their own. If those events are sent through the log bridge, exporting a log could produce another log, which could trigger another export, and so on.
The service code avoids that feedback loop by filtering infrastructure targets out of the OpenTelemetry log layer. It still allows warnings and errors from that infrastructure to appear in the console layer, which is useful when the collector is unavailable.
This is a small implementation detail, but it is exactly the sort of thing that turns a “hello, telemetry” demo into a setup you can operate.
Metrics are collected when requested
OpenTelemetry observable instruments use callbacks that run during collection. They are a good fit for values such as current CPU or memory usage: the application registers how to read the value, and the SDK asks for it when a metrics reader collects data.
In this project, the server registers observable system metrics using sysinfo. The callbacks refresh and read the relevant values; there is no separate polling task that wakes up just to update gauges. If you adapt the example for your own service, you can add application counters or histograms alongside those system metrics.
One caveat for a demo: the OTLP example initializes all three pipelines and emits spans and log records, but it does not record a custom metric. To demonstrate all three signals end to end, add a metric instrument or run the server path that registers the system metrics.
What should you take away?
OpenTelemetry Rust 0.33.1 is a meaningful point to revisit your observability setup:
1. Logs and metrics have stable OTLP exporters. That is useful if you want to standardize your output format and backend integration.
2. Tracing is still beta. Treat its API stability separately rather than assuming every signal has the same guarantee.
3. Retries are now on by default for OTLP exporters. That improves resilience to transient failures, but it is still worth monitoring exporter errors and having a clear shutdown path.
4. tracing can feed more than one signal. Use the trace bridge for spans and the appender for OpenTelemetry log records, and filter exporter-generated events to prevent feedback loops.
The interesting question is no longer just “Can Rust send telemetry to OpenTelemetry?” It can. The more useful question is: which signals are stable in the versions you deploy, and what does your application do when the collector is slow or unavailable?
References
- OpenTelemetry Rust 0.33 release notes
- opentelemetry-otlp changelog
- opentelemetry-otlp 0.33.1 documentation
- opentelemetry-appender-tracing documentation
MCP Monitoring: Why Your Agents Fail Silently and How to Catch It
Your MCP server is returning 200s. Your uptime dashboard is green. And an agent somewhere just told a user "I couldn't complete that request" for the fortieth time today.
That gap is what MCP monitoring is about. When an MCP server breaks, a person rarely sees an error page. An agent reads a failure, retries, picks a different tool, or gives up and summarizes the problem in natural language. None of that shows up in a classic HTTP monitor.
This guide covers why MCP needs its own monitoring, which signals are worth capturing, the main tools you can use today, and a walkthrough of one hosted option.
Why MCP monitoring is different from API monitoring
Classic API monitoring assumes a request, a status code, and a human or a service that notices when things go wrong. MCP breaks most of those assumptions.
Failures can be invisible at the transport layer. In MCP, a tool that fails can return a normal JSON-RPC result with isError: true in the body. The HTTP response is still a success. If you only count 5xx responses, you will miss every tool-level error.
Agents don't file tickets. A human who hits a broken endpoint tries once and complains. An agent may retry in a loop, burn tokens, and move on. The only witness is the traffic itself.
Tool misuse looks like success. An agent can call the wrong tool, or the right tool with poorly chosen arguments, and get a valid response. Usually the cause is a vague description or two tools that look alike. Your server did its job. The user still got a bad answer.
Latency compounds. One agent task can chain several tool calls. A tool with a slow p95 doesn't just slow one request. It stretches the whole task, and clients may time out partway through.
Schemas drift. Agents read your tools/list output and take input schemas literally. Rename a parameter or remove a tool, and clients that cached the old shape start failing. Nothing on the server side registers it as an error.
The handshake can break while the host is up. A server can answer a ping and still fail initialize, return an HTML error page where JSON-RPC should be, or reject credentials after an OAuth change.
What signals to capture
Whatever tool you pick, aim to capture these:
- Handshake health: whether initialize succeeds, how long it takes, and which protocol version and transport were negotiated.
- Tool inventory: the current tools/list output, compared against the previous one, so added, removed, or changed tools are visible.
- Per-tool error rate: including results with isError: true, not just protocol or HTTP errors.
- Latency percentiles per tool: p50 and p95 at minimum, since averages hide the slow tail agents time out on.
- Caller identity: which client or agent made the call (Claude, Cursor, a partner's bot), and which key or user it used.
- Token or payload size: large, unpaginated responses are expensive for the agent even when they succeed.
- Retries and loops: repeated calls to the same tool with similar arguments in a short window.
- Trace context: a trace ID that links the agent's LLM call to the tool call to your backend, so you can follow one task end to end.
Approaches and tools
There is no single right layer. Some teams instrument the server code, some trace the client, some put a gateway in front, and some run synthetic checks from outside. Most production setups combine at least two of these.
OpenTelemetry with the MCP semantic conventions
OpenTelemetry now has semantic conventions for MCP, currently marked as in development. They define client and server spans named {mcp.method.name} {target}, for example tools/call get-weather, with attributes such as mcp.method.name, mcp.protocol.version, and gen_ai.tool.name. They also define duration metrics like mcp.server.operation.duration.
Trace context travels inside the MCP request's params._meta rather than only in HTTP headers, which SEP-414 documents in the MCP spec. That matters because one MCP request can span several HTTP requests, and one HTTP stream can carry several MCP messages.
The official Python and TypeScript MCP SDKs emit these spans once you register an OpenTelemetry SDK. Per the Python SDK docs, a tool result with is_error=True sets the span status to error, which covers the silent-failure case above. The TypeScript docs note that spans don't record tool arguments or results by default.
From there you can send traces to any OpenTelemetry-compatible backend, such as Grafana Tempo, Jaeger, or a vendor platform. This is the most portable approach and works for self-hosted servers. You build the dashboards and alerts yourself.
Datadog Agent Observability
Datadog's MCP client guide covers automatic instrumentation for the official Python and JavaScript MCP SDKs, and manual instrumentation for other clients, including Java. For Python, the setup from their docs is:
pip install ddtrace
export DD_LLMOBS_ENABLED=true
export DD_LLMOBS_ML_APP=<YOUR_ML_APP_NAME>
export DD_LLMOBS_AGENTLESS_ENABLED=true
export DD_API_KEY=<YOUR_API_KEY>
ddtrace-run <YOUR_APP_STARTUP_COMMAND>
According to Datadog's auto-instrumentation docs, the Python integration also instruments the server side (initialize and tools/call), tagging spans with the client name and version. The manual instrumentation example explicitly marks a span as an error when result.isError is true. Their announcement post describes linking MCP spans to the LLM span that chose the tool, with aggregated p95 latency and error rates per tool and server.
This fits best if you already run Datadog and want MCP traces next to the rest of your APM data.
Speakeasy Gram tool logs
If your servers are hosted on Speakeasy's platform, the Tool Logs page is a raw log of every tool call. Its docs list filters for server, client (as reported at the MCP handshake), status, agent, and user, plus a summary strip with failure rate and blocked rate. MCP Insights rolls this up into call volumes and the tools with the most errors. Logs can also be exported to a collector, and there's an API for searching them.
MCP Inspector
The MCP Inspector is the official interactive tool for connecting to a server, listing its tools, and calling them by hand. It isn't a monitoring system, since nothing runs on a schedule and nothing alerts you. It's still the quickest way to reproduce a failure once your monitoring has flagged one.
Elva MCP Monitor and MCP logs
Elva, which builds hosted MCP servers from APIs, takes two angles. Elva-hosted servers come with call logs and insights. A newer feature, MCP Monitor, runs scheduled synthetic checks against any remote MCP server, including ones not hosted on Elva. The walkthrough below follows Elva's documentation.
Walkthrough: setting up an MCP monitor in Elva
Everything in this section comes from the MCP Monitor docs.
What each check does
Every check runs up to three steps:
1. Handshake: runs the MCP initialize handshake and records latency, transport, and protocol version.
2. Tool inventory: lists the tools and compares names and input schemas with the previous check. This is the schema drift signal from earlier.
3. Read-only tool call (optional): calls one read-only tool with arguments you choose, as a synthetic test.
The result is one of four statuses:
- Up: the handshake succeeded and every enabled check passed.
- Degraded: the server answered, but tools/list failed, the tool call failed, or latency went over your threshold.
- Down: no valid MCP response, for example DNS or TLS failure, timeout, 5xx, an HTML page instead of JSON-RPC, or rejected credentials.
- Auth required: the server needs a credential or an OAuth reconnect before it can be checked.
A 429 is reported as rate limited, not as down.
Step 1: Add the server
Open MCP Monitor and enter the server URL, or pick one of your Elva-hosted servers.
Step 2: Authenticate
Choose None, Bearer token, API key header, Custom header, or OAuth. Per the docs, saved headers are encrypted at rest and not shown again.
Step 3: Choose what to check
Set the interval (every 1, 5, 15, 30, or 60 minutes), an optional latency threshold in milliseconds, and optionally one read-only tool to call. Free workspaces can check every 5 minutes or slower.
Step 4: Set alerts
Choose how many consecutive failed checks open an incident (2 by default), whether degraded checks count, and whether to notify when the tool inventory changes. Alerts can go to a Slack incoming webhook, up to five email recipients, or a signed webhook. Each monitor has Checks, Incidents, Tools, Alerts, and Settings tabs, and Send test on the Alerts tab tries every configured channel.
An incident opens when the failure threshold is reached and resolves on the next healthy check.
Handling webhook alerts
Webhook events are mcp_monitor.incident.opened, mcp_monitor.incident.resolved, mcp_monitor.tools.changed, and mcp_monitor.test. Here's the example incident payload from the docs:
{
"event": "mcp_monitor.incident.opened",
"occurredAt": "2026-11-02T10:00:00.000Z",
"monitor": {
"name": "Orders MCP",
"url": "https://mcp.example.com/mcp",
"status": "down",
"slug": "orders-mcp",
"statusPageUrl": "https://app.getelva.ai/status/orders-mcp"
},
"incident": {
"startedAt": "2026-11-02T09:58:00.000Z",
"cause": {
"status": "down",
"failureCode": "timeout",
"message": "Handshake timed out"
}
}
}
Tool-change events include a toolsDiff with added, removed, and changed tools. Deliveries are signed with the same scheme as Elva's MCP webhooks, using the X-Elva-Signature and X-Elva-Timestamp headers, so verify them before acting on an event.
Status pages and one-off checks
You can publish a public status page at app.getelva.ai/status/<slug> and embed an SVG badge in a README. There's also a one-off check you can run without signing in, which produces a shareable report on the handshake, protocol version, transport, tools, and OAuth discovery.
On plan limits, the docs say Free workspaces can save 10 monitors with a 5-minute minimum interval.
Looking at real traffic
Synthetic checks tell you whether a server works. They don't tell you what agents actually do with it. For Elva-hosted servers, the MCP insights and logs page covers the traffic side: total requests, response-time percentiles, error rate, and token estimates over a date range, plus per-agent cards. Calling clients such as Claude, Cursor, Windsurf, and MCP Inspector are detected automatically from their requests. You can also follow logs from the terminal with elva-cli 1.2.0 or later:
elva mcp logs orders-mcp --follow
I covered Elva briefly in an earlier post, Postman Alternatives in 2026, from the API testing side. This post looks at the monitoring side.
How to choose
- You run your own server and want portability: use the OpenTelemetry MCP conventions through the official SDKs, then send the data to the backend you already use.
- You're already on Datadog: its MCP integration puts tool calls inside the same traces as the LLM calls that triggered them.
- Your servers live on a hosted platform: start with that platform's logs (Gram's Tool Logs, Elva's MCP logs), then add external checks.
- You need to know when a server breaks for agents, wherever it's hosted: add a synthetic monitor that runs the handshake and checks the tool list on a schedule. Elva's MCP Monitor is one option. A scheduled script using an official MCP SDK client is another.
- You're debugging one failure right now: open MCP Inspector.
MCP monitoring checklist
- [ ] Count tool results with isError: true as errors, not just HTTP and JSON-RPC errors.
- [ ] Track p50 and p95 latency per tool, not per server.
- [ ] Record which client or agent made each call.
- [ ] Snapshot tools/list and alert when names or input schemas change.
- [ ] Run a scheduled initialize handshake from outside your network.
- [ ] Include at least one read-only synthetic tool call.
- [ ] Propagate trace context through params._meta so agent, tool, and backend spans connect.
- [ ] Watch response size and token usage for unpaginated tools.
- [ ] Alert on repeated retries of the same tool from the same caller.
- [ ] Treat auth and OAuth failures as their own status, separate from downtime.
- [ ] Keep MCP Inspector handy for reproducing what your monitors find.
3 reactions · 1 comment
The implementation follows OpenTelemetry's semantic conventions for MCP, defining how MCP operations are traced and measured
github.com/open-telemetry/semantic-conventions/bl…
1 like · 1 repost · 314 views
AIエージェントの実行証跡をどう設計するか ― 監査・説明責任・障害調査
AIエージェントが業務システムを操作したとき、後からその処理をどこまで追跡できる必要があるだろうか。
通常のWebアプリケーションでも、アクセスログや監査ログを使って操作履歴を確認できる。
では、AIエージェントでも同じログを収集すれば十分なのだろうか。
AIエージェントでは、ユーザーの依頼を受けてLLMがToolを選択し、取得した情報をもとに次の操作を判断する。
そのため、業務APIの呼び出し結果だけでは、実行に至った経緯を把握できない場合がある。
今回は社内の経費精算システムを例に、監査・説明責任・障害調査・セキュリティという複数の目的を満たす実行証跡をどう設計するか考える。
1. 実行証跡は何のために必要なのか
まず、ログを収集する目的を整理する。
目的
確認したいこと
監査
誰が何を実行し、権限や手続きに問題がなかったか
説明責任
AIがどの情報をもとに何を行ったか説明できるか
障害調査
どこで失敗し、なぜ期待した結果にならなかったか
セキュリティ
不正な操作や異常な動作が発生していないか
目的は異なるが、必要な情報には重複が多い。
例えば、Toolの実行履歴は、障害調査だけでなく監査やセキュリティ調査にも利用できる。
そこで、目的ごとに別々のログ出力処理を実装するのではなく、共通の実行証跡を収集し、複数の目的で活用する構成を考える。
また、証跡を共通的に収集することと、それぞれの目的の要件を満たすことは別だ。
例えば監査では、必要な記録を欠落させないことや、保存期間、改ざん防止などが求められる場合がある。
説明責任についても、ログだけで完結するわけではなく、責任者の明確化や問題発生時の対応も必要になる。
収集はできるだけ共通化し、保存・管理は目的に応じて設計する。
2. 通常のWebアプリと何が違うのか
経費精算システムで、先週の大阪出張の経費を提出する場面を考える。
通常のWebアプリでは、次のように操作する。
ユーザーが申請一覧画面を開く
↓
アプリが申請一覧APIを呼ぶ
↓
ユーザーが対象申請を選ぶ
↓
アプリが申請詳細APIを呼ぶ
↓
ユーザーが内容を確認して提出
↓
アプリが提出APIを呼ぶ
アクセスログで誰がいつAPIを呼び出したかを確認し、業務DBや変更履歴から申請内容を調査できる。
これらが適切に記録・関連付けられ、監査要件を満たしているなら、専用の監査ログを別途実装する必要はない。
一方、AIエージェントには、ユーザーが次のように依頼する。
先週の大阪出張の経費を提出しておいて。
この場合は、次のように処理が進む。
ユーザーが経費精算を依頼
↓
LLMが申請一覧取得Toolを選択・実行
↓
LLMが対象申請を選ぶ
↓
LLMが申請詳細取得Toolを選択・実行
↓
LLMが提出Toolを選択
↓
ユーザーが内容を確認して承認
↓
提出Toolから提出APIを呼ぶ
どちらも申請一覧の取得、申請内容の取得、提出という業務処理は同じだ。
違うのは、通常のWebアプリではユーザーが対象や操作を選び、アプリが決められたAPIを呼ぶのに対し、AIエージェントではLLMが取得した情報をもとに次のToolや対象を選ぶことだ。
例えば、エージェントが間違った申請を提出してしまったとする。
業務サービスの記録からは、どの申請が提出されたか確認できる。しかし、それだけでは次の疑問に答えられない。
ユーザーは何を依頼したのか
エージェントはどの申請情報を参照したのか
どのToolと引数を選択したのか
ユーザーはどの内容を承認したのか
これらを調査するには、業務APIの呼び出し以前の実行経緯が必要になる。
ただし、LLMの内部的な推論過程を完全に記録・再現できるわけではない。後からLLMに判断理由を説明させても、実際の判断過程を正確に表している保証はない。
重要なのは、内部的な思考を再現することではなく、参照情報、LLMの出力、Toolの選択、承認、実行結果など、実際に観測できた事実を追跡することだと考える。
3. 何を記録する必要があるのか
経費精算エージェントを例に、記録対象を整理する。
分類
記録情報の例
共通的に収集
実行日時、実行主体、実行ID、Tool名、呼び出し結果
イベント発生時に記録
認可失敗、承認・否認(承認者・対象・日時・結果など)、重要な業務状態変更
リスク・監査要件に応じて追加
認可成功の詳細、適用したルール、対象データ、モデルやAgent定義のバージョン
個別に判断
依頼全文、LLM入出力、Tool引数・結果の全文、参照文書の内容
この分類は設計の出発点であり、すべてのシステムで必須となる項目を示したものではない。
参照した情報をどう残すか
例えば、エージェントが誤った申請を選択した場合。
原因を調査するには、LLMがどのような申請情報を受け取っていたか確認したい。
しかし、取得した申請一覧をすべてログに保存すると、情報量が増え、個人情報の管理も難しくなる。
そこで、参照データのIDやバージョンを記録し、必要な場合に実行時点のデータを確認する方法が考えられる。
ただし、データが更新・削除されるなら履歴管理が必要になる。また、取得したデータを加工してLLMに渡す場合は、その内容も追跡できるか検討する。
モデルやプロンプトの変更が誤処理に影響する可能性もある。
重要な処理では、モデル識別子、プロンプトやAgent定義のバージョン、Toolの実装バージョンなども記録候補になる。
実行されなかった操作も記録する
もう一つ重要なのが、Tool実行前に拒否された操作だ。
例えば、エージェントが権限外の申請を提出しようとしたが、認可処理で拒否された場合。
業務APIが呼び出されていなければ、そのアクセスログには何も残らない。
しかし、セキュリティ調査では、エージェントが何を実行しようとし、どの制御によって拒否されたかが重要になる。
認可失敗は原則として記録する。また、承認・否認が行われた場合は、操作の重要度だけで判断せず、誰が何に対してどのような判断をしたかを残す。認可成功の詳細をどこまで記録するかは、業務リスクや監査要件に応じて決める。
OWASPのAI Agent Security Cheat Sheetでも、高リスク操作の認可結果、承認ID、適用したポリシー、実行結果などの記録が推奨されている。
4. 実行証跡をどう収集するか
必要な情報を整理できたら、次はどこで収集するかを考える。
すべてを個別実装する必要はない。既存のLoggerやOpenTelemetryを活用し、不足する情報だけを追加する。
共通Loggerで収集する
日時、ユーザーID、リクエストIDなどは、共通のログフォーマットで出力できる。
例えばJavaなら、SLF4JのMDCなどを使い、処理中のログにユーザーIDやリクエストIDを自動付与できる。
AIエージェントでは、それに加えて一連の実行を関連付けるIDを用意したい。
例えば、次のような関係になる。
経費精算の依頼(実行ID:run-001)
│
├─ 申請一覧を取得
│
├─ 申請内容を取得
│
├─ 提出操作の承認待ち
│
└─ 承認後に提出APIを実行
各Toolの呼び出しにも個別の識別子を付け、一連の実行IDと関連付ける。
実行IDは処理コンテキストを通じて引き継ぎ、各コンポーネントのログやトレースに付与する。追跡のために、既存の業務DBの各テーブルへ実行IDを追加する必要はない。
承認待ちなどで処理が中断され、翌日再開される場合もある。
HTTPリクエストや分散トレースのIDは再開前後で変わる場合があるため、一連の業務依頼を追跡できるIDを保持するとよい。
なお、IDの名称や管理方法は利用するフレームワークによって異なる。既存の識別子を活用できるなら、新たに作る必要はない。
OpenTelemetryで収集する
LLMやToolの実行については、OpenTelemetryのGenAI Semantic Conventionsを活用できる。
これは、生成AIの処理をトレースするために、記録する情報の名前や形式を共通化する仕様だ。
例えば、次のような情報を表現できる。
情報
OpenTelemetryの属性例
実行した処理
gen_ai.operation.name
Tool名
gen_ai.tool.name
Tool呼び出しID
gen_ai.tool.call.id
Toolの引数
gen_ai.tool.call.arguments
Toolの結果
gen_ai.tool.call.result
ここで注意したいのは、OpenTelemetryを導入すれば、すべての情報が自動的に記録されるわけではないことだ。
実際に取得できる内容は、利用するフレームワークや計測ライブラリの対応状況によって異なる。
また、Toolの引数やLLMの入出力には機密情報が含まれる可能性があるため、必要な場合に限定して取得する。
OpenTelemetryは共通の記録形式を提供するが、何を取得し、どこまで残すかはシステム側で設計する必要がある。
なお、GenAI関連の仕様は更新が続いているため、実装時には利用するバージョンと対応状況を確認する。
参考:OpenTelemetry GenAI Semantic Conventions
どのコンポーネントで記録するか
既存の経費精算システムにAIエージェントを追加する場合、例えば次の構成になる。
ユーザー
↓
Agent基盤
│ Tool選択・承認
↓
MCP Server
│ Tool受付・実行
↓
業務API
│ 業務処理
↓
業務DB
記録対象は次のように分けられる。
コンポーネント
主な記録対象
Agent基盤
LLM呼び出し、Tool選択、承認・拒否
MCP Server
Toolの受付・実行結果
業務サービス
業務処理結果、対象データの変更
例えば、Toolの呼び出しが正常終了しても、非同期で実行される後続の業務処理が失敗することはある。
そのため、最終的な業務処理結果は業務サービス側で確認する。
既存のアクセスログやDBの変更履歴を置き換えるのではなく、Agent側の実行証跡と関連付けて活用する。
これにより、誤った経費申請が提出された場合も、次のように調査できる。
実行IDからユーザーの依頼を特定する
Agent側の記録から参照情報とToolの選択を確認する
承認履歴からユーザーが何を承認したか確認する
業務サービスの記録から実際の提出結果を確認する
これらを関連付けることで、最初の依頼から実際の業務操作までを追跡できる。
5. 実行証跡をどう保存・管理するか
ここまで収集方法を整理したが、取得した情報をすべて同じ方法で保存すればよいわけではない。
例えば、分散トレースでは性能調査のために一部の記録を間引いたり、短期間で削除したりする場合がある。
一方、監査に必要な操作履歴を同じように間引くと、必要な証跡が残らない可能性がある。
そこで、記録項目は共通化しつつ、保存方法は目的に応じて分ける構成を考える。
これは論理的な構成であり、必ず別々のログ基盤を用意するという意味ではない。
同じ基盤を使いながら、記録の種類によって保存期間や保存方法を変えることもできる。
ただし、収集や転送の途中で失われた記録は、保存先を分けても復元できない。
そのため、監査に必要な重要イベントでは、収集から保存までの一連の処理で欠落を防ぐ設計が必要になる。
監査に必要な管理要件
監査に利用する記録では、主に次の点を検討する。
観点
設計内容
記録の欠落防止
必要な操作履歴を確実に残す
保存期間
業務・法令・契約要件に従って決める
改ざん対策
不正な変更・削除を防止・検知する
アクセス制御
閲覧・管理できる人を制限する
機密保護
個人情報や認証情報を保護する
追跡可能性
関連するログやDB情報を対応付ける
これらは従来のWebアプリでも必要な設計であり、AIエージェント固有の仕組みを作る必要はない。
ただし、重要な操作では、ログの保存に失敗した場合の扱いも決める必要がある。
例えば、経費申請の正式提出で、提出自体は成功したのに必要な監査記録が残らなかったとする。
この状態を許容できない業務なら、業務処理と監査記録の整合性を確保する必要がある。
業務DBと同じトランザクションで記録する方法や、記録を確実に転送・再送する仕組みなどが考えられる。
重要なのは、ログ出力処理を追加しただけで、監査に必要な記録が必ず残るとは限らないことだ。
参考:OWASP Logging Cheat Sheet
6. まとめ
今回は、AIエージェントの実行証跡をどう設計するか考えた。
最初の疑問は、
「AIエージェントでも、従来のWebアプリと同じログを収集すれば十分なのか?」
だった。
私の答えは、「従来のログ設計を基本にできるが、LLMによるTool選択や情報参照、承認・拒否など、業務API実行前の経緯も追跡する必要がある」だ。
今回の設計では、次のように整理した。
監査・説明責任・障害調査・セキュリティに必要な情報を、共通の実行証跡として収集する
LoggerやOpenTelemetryを活用し、個別実装を最小限にする
業務固有の情報は既存システムで管理し、実行IDなどで関連付ける
監査に必要な保存期間、改ざん対策、記録の欠落防止などは別途満たす
LLMの入出力やTool引数など、機密情報を含む内容の記録範囲は慎重に決める
AIエージェントの実行証跡設計では、ログの種類を増やすのではなく、何を追跡できる必要があるかを整理し、既存の仕組みと組み合わせて証跡を確保する。
これが今回の設計の結論だ。
Claude CodeのOpenTelemetryをBigQueryに流してAIコーディングのコストと失敗を可視化する
はじめに
AIコーディングエージェントを日常業務に組み込むと、ほどなくして「で、これ結局いくらかかってるんだ?」という疑問にぶつかります。
月末の請求額は分かる。でも、どのタスクが高かったのか、どのツール呼び出しが失敗を繰り返しているのか、キャッシュはちゃんと効いているのかは分からない。ダッシュボードの合計値だけ見て「まあ、こんなもんか」と流している方も多いのではないでしょうか。
これは要するにオブザーバビリティの問題です。そして幸いなことに、Claude Code は OpenTelemetry をネイティブでサポートしています。メトリクス・イベント・分散トレースを OTLP で吐き出せるので、あとは受け止めて分析するだけです。
この記事では、Claude Code のテレメトリを OpenTelemetry Collector で受け、GCP 経由で BigQuery に着地させて SQL で分析するまでを構築します。普段 GA4×BigQuery でデータ基盤を触っている人間にとっては、慣れた道具でそのまま料理できる構成です。
!
本記事の設定ファイルは OpenTelemetry Collector v0.115.0(contrib ディストリビューション)で validate と疎通を確認済みです。Claude Code 側の環境変数・メトリクス名・属性名は公式ドキュメントの記載に準拠しています。
自己紹介
普段は EC 事業者向けに GA4×BigQuery のデータ基盤構築と分析を請け負っています。Looker Studio でのダッシュボード化、dbt での 3 層設計、最近は Claude Code を使った SQL 生成・レポート自動化まわりが主戦場です。
つまり「BigQuery にデータを置いて SQL で殴る」のが手癖なので、今回もその型に持ち込みます。
なぜ「AIエージェントの可視化」なのか
従来のオブザーバビリティは、リクエストを受けてレスポンスを返すサーバーが対象でした。AIエージェントはここに厄介な性質を持ち込みます。
1回の指示のコストが数十倍ブレる — 同じ「バグを直して」でも、5秒で終わることも、20回ツールを叩いて数ドル溶かすこともある
失敗が可視化されにくい — ツール実行が失敗してもエージェントがリトライして最終的に成功するので、内部の失敗率は表に出てこない
コストの内訳が入れ子 — サブエージェント、スキル、MCP サーバー呼び出しが階層的に発生する
「合計いくら」ではなく「どのプロンプトが、どの経路で、いくら使ったか」が見えないと改善のしようがありません。これはまさにトレースとメトリクスが解く問題です。
全体構成
Claude Code (OTLP)
│
├─ metrics ─┐
├─ logs ────┼─→ OpenTelemetry Collector
└─ traces ──┘ │
├─→ Cloud Monitoring (メトリクス:アラート・監視)
├─→ Cloud Trace (トレース:1プロンプトの内訳)
└─→ Cloud Logging (イベント)
│
└─→ ログシンク → BigQuery → SQL 分析
ポイントは イベント(ログ)を BigQuery に落とすことです。Claude Code の claude_code.api_request イベントにはトークン数とコストが属性として乗っているため、ここさえ BigQuery に入れば、あとは使い慣れた SQL で好きなだけ切り刻めます。
メトリクスは Cloud Monitoring に置いてアラート用に使い、BigQuery 側は分析用と役割分担させるのがすっきりします。
Step 1:Claude Code のテレメトリを有効化する
環境変数だけで有効になります。
export CLAUDE_CODE_ENABLE_TELEMETRY=1
エクスポート先
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_ENDPOINT=localhost
ツールの詳細(Bash のコマンド内容や失敗メッセージ)まで記録する
export OTEL_LOG_TOOL_DETAILS=1
検証中は出力間隔を短くすると即座に確認できる
export OTEL_METRIC_EXPORT_INTERVAL=10000
export OTEL_LOGS_EXPORT_INTERVAL=2000
取得できる主なメトリクスは以下です。
メトリクス
内容
claude_code.cost.usage
セッションのコスト(USD)
claude_code.token.usage
消費トークン数
claude_code.lines_of_code.count
変更行数
claude_code.commit.count
作成したコミット数
claude_code.pull_request.count
作成した PR 数
claude_code.session.count
セッション数
claude_code.active_time.total
アクティブ時間(秒)
そして今回の主役、イベント側です。
イベント
内容
claude_code.api_request
API リクエスト1回ごとのコスト・トークン・レイテンシ
claude_code.tool_result
ツール実行の成否・所要時間・エラー種別
claude_code.api_error
API 呼び出しの失敗
claude_code.tool_decision
ツール実行許可の判断
claude_code.user_prompt
ユーザーのプロンプト送信
!
OTEL_LOG_TOOL_DETAILS=1 を有効にすると、Bash で実行したコマンド文字列やツールの引数まで記録されます。便利な反面、機密情報が混ざる可能性があります。共有のバックエンドに送る場合は、この設定を入れるかどうかを必ず検討してください。また既定でも user.email などが送信対象になり得ます。
Step 2:OpenTelemetry Collector を立てる
まずローカルで「ちゃんと届いているか」を目視できる構成から始めるのが確実です。いきなり GCP に繋ぐと、届かなかったときに Claude Code 側の問題なのか Collector なのか GCP 権限なのか切り分けられなくなります。
local-test.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 127.0.0.1:4317
http:
endpoint: 127.0.0.1:4318
processors:
batch:
timeout: 1s
exporters:
debug:
verbosity: detailed
file:
path: ./out.jsonl
service:
pipelines:
metrics:
receivers: [otlp]
processors: [batch]
exporters: [debug, file]
logs:
receivers: [otlp]
processors: [batch]
exporters: [debug, file]
traces:
receivers: [otlp]
processors: [batch]
exporters: [debug, file]
起動します。
contrib 版が必要(googlecloud exporter は contrib にのみ含まれる)
curl -sSLO github.com/open-telemetry/opentelemetry-collector…
tar xzf otelcol-contrib_0.115.1_linux_amd64.tar.gz
設定ファイルの妥当性を先に確認できる
./otelcol-contrib validate --config local-test.yaml
./otelcol-contrib --config local-test.yaml
この状態で別ターミナルから Claude Code を起動して何か作業させると、out.jsonl にテレメトリが流れ込みます。ここで claude_code.cost.usage や claude_code.tool_result が見えれば、Claude Code 側の設定は正しいと確定できます。
Step 3:GCP に送る
疎通が取れたら送り先を差し替えます。googlecloud exporter はトレース・メトリクス・ログの 3 シグナルすべてに対応しており、それぞれ Cloud Trace / Cloud Monitoring / Cloud Logging に着地します。
gcp.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 127.0.0.1:4317
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 65
spike_limit_percentage: 20
resource:
attributes:
- key: service.name
value: claude-code
action: upsert
batch:
timeout: 10s
exporters:
googlecloud:
project: your-project
log:
default_log_name: claude-code-otel
service:
pipelines:
metrics:
receivers: [otlp]
processors: [memory_limiter, resource, batch]
exporters: [googlecloud]
logs:
receivers: [otlp]
processors: [memory_limiter, resource, batch]
exporters: [googlecloud]
traces:
receivers: [otlp]
processors: [memory_limiter, resource, batch]
exporters: [googlecloud]
認証はサービスアカウント経由が確実です。
gcloud iam service-accounts create otel-collector --project=your-project
for role in roles/monitoring.metricWriter roles/logging.logWriter roles/cloudtrace.agent; do
gcloud projects add-iam-policy-binding your-project \
--member="serviceAccount:otel-collector@your-project.iam.gserviceaccount.com" \
--role="$role"
done
gcloud iam service-accounts keys create ./otel-sa.json \
--iam-account=otel-collector@your-project.iam.gserviceaccount.com
export GOOGLE_APPLICATION_CREDENTIALS=./otel-sa.json
./otelcol-contrib --config gcp.yaml
memory_limiter を入れておくのは実運用上わりと大事で、これが無いとバックエンド側が詰まったときに Collector がメモリを食い潰します。
Step 4:Cloud Logging から BigQuery へシンクする
ここが BigQuery に持ち込む肝です。ログシンクを作ると、以後のログエントリが自動的に BigQuery のテーブルへ流れ込みます。
受け皿のデータセット
bq --location=asia-northeast1 mk --dataset your-project:claude_code_otel
シンク作成
gcloud logging sinks create claude-code-to-bq \
bigquery.googleapis.com/projects/your-project/datasets/claude_code_otel \
--log-filter='logName:"claude-code-otel"' \
--project=your-project
作成時に出力されるサービスアカウントに、データセットへの書き込み権限を付与します。
出力された writerIdentity を控えて付与する
gcloud projects add-iam-policy-binding your-project \
--member="serviceAccount:<出力されたwriterIdentity>" \
--role="roles/bigquery.dataEditor"
!
シンクは作成後に発生したログにのみ適用されます。過去分は遡って入りません。先にシンクを作ってから Claude Code を回し始めるのが正解です。
Step 5:BigQuery で分析する
まずテーブル構造を確認する
ログシンク経由のテーブルは、ログの中身に応じてスキーマが自動生成されます。属性が jsonPayload の下に入るのか labels に入るのかは、Collector のバージョンやログの形で変わります。最初に必ず実物を1行見てください。
SELECT *
FROM your-project.claude_code_otel.claude_code_otel_*
LIMIT 1;
-- カラム名を一覧で確認する
SELECT column_name, data_type
FROM your-project.claude_code_otel.INFORMATION_SCHEMA.COLUMNS
WHERE table_name LIKE 'claude_code_otel_%'
ORDER BY ordinal_position;
以下の SQL は属性が jsonPayload.attributes 配下に入る前提で書いています。実際の構造に合わせてパスを読み替えてください。
① 日別・モデル別のコスト推移
SELECT
DATE(timestamp, 'Asia/Tokyo') AS dt,
JSON_VALUE(jsonPayload.attributes, '$.model') AS model,
COUNT(*) AS requests,
ROUND(SUM(CAST(JSON_VALUE(jsonPayload.attributes, '$.cost_usd') AS FLOAT64)), 4) AS cost_usd,
SUM(CAST(JSON_VALUE(jsonPayload.attributes, '$.input_tokens') AS INT64)) AS input_tokens,
SUM(CAST(JSON_VALUE(jsonPayload.attributes, '$.output_tokens') AS INT64)) AS output_tokens
FROM your-project.claude_code_otel.claude_code_otel_*
WHERE JSON_VALUE(jsonPayload.attributes, '$."event.name"') = 'api_request'
AND DATE(timestamp) >= DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 30 DAY)
GROUP BY dt, model
ORDER BY dt DESC, cost_usd DESC;
② キャッシュがどれだけ効いているか
プロンプトキャッシュはコストに直結します。cache_read_tokens の比率が低いなら、セッションの回し方に改善余地があります。
SELECT
DATE(timestamp, 'Asia/Tokyo') AS dt,
SUM(CAST(JSON_VALUE(jsonPayload.attributes, '$.input_tokens') AS INT64)) AS input_tokens,
SUM(CAST(JSON_VALUE(jsonPayload.attributes, '$.cache_read_tokens') AS INT64)) AS cache_read,
SUM(CAST(JSON_VALUE(jsonPayload.attributes, '$.cache_creation_tokens') AS INT64)) AS cache_creation,
ROUND(
SAFE_DIVIDE(
SUM(CAST(JSON_VALUE(jsonPayload.attributes, '$.cache_read_tokens') AS INT64)),
SUM(CAST(JSON_VALUE(jsonPayload.attributes, '$.input_tokens') AS INT64))
) * 100, 1
) AS cache_hit_pct
FROM your-project.claude_code_otel.claude_code_otel_*
WHERE JSON_VALUE(jsonPayload.attributes, '$."event.name"') = 'api_request'
GROUP BY dt
ORDER BY dt DESC;
③ どのツールが失敗しているか
これが個人的に一番効きました。エージェントはツールが失敗しても勝手にリトライして最終的に成功させてしまうので、失敗は表に出てきません。しかし失敗はそのままトークンの浪費です。
SELECT
JSON_VALUE(jsonPayload.attributes, '$.tool_name') AS tool_name,
JSON_VALUE(jsonPayload.attributes, '$.error_type') AS error_type,
COUNT(*) AS n,
ROUND(AVG(CAST(JSON_VALUE(jsonPayload.attributes, '$.duration_ms') AS INT64))) AS avg_ms
FROM your-project.claude_code_otel.claude_code_otel_*
WHERE JSON_VALUE(jsonPayload.attributes, '$."event.name"') = 'tool_result'
AND JSON_VALUE(jsonPayload.attributes, '$.success') = 'false'
GROUP BY tool_name, error_type
ORDER BY n DESC
LIMIT 20;
ツール別の成功率も並べて見ておきます。
SELECT
JSON_VALUE(jsonPayload.attributes, '$.tool_name') AS tool_name,
COUNT(*) AS calls,
COUNTIF(JSON_VALUE(jsonPayload.attributes, '$.success') = 'false') AS failures,
ROUND(
COUNTIF(JSON_VALUE(jsonPayload.attributes, '$.success') = 'false') / COUNT(*) * 100, 1
) AS failure_pct
FROM your-project.claude_code_otel.claude_code_otel_*
WHERE JSON_VALUE(jsonPayload.attributes, '$."event.name"') = 'tool_result'
GROUP BY tool_name
HAVING calls >= 10
ORDER BY failure_pct DESC;
④ 「高かったプロンプト」を特定する
prompt.id は、1つのユーザープロンプトから派生したすべてのイベントに付与される UUID です。これを使うと 「あの指示、結局いくらかかったの?」に答えられます。
WITH per_prompt AS (
SELECT
JSON_VALUE(jsonPayload.attributes, '$."prompt.id"') AS prompt_id,
MIN(timestamp) AS started_at,
SUM(CASE WHEN JSON_VALUE(jsonPayload.attributes, '$."event.name"') = 'api_request'
THEN CAST(JSON_VALUE(jsonPayload.attributes, '$.cost_usd') AS FLOAT64) END) AS cost_usd,
COUNTIF(JSON_VALUE(jsonPayload.attributes, '$."event.name"') = 'api_request') AS api_calls,
COUNTIF(JSON_VALUE(jsonPayload.attributes, '$."event.name"') = 'tool_result') AS tool_calls,
COUNTIF(JSON_VALUE(jsonPayload.attributes, '$."event.name"') = 'tool_result'
AND JSON_VALUE(jsonPayload.attributes, '$.success') = 'false') AS tool_failures
FROM your-project.claude_code_otel.claude_code_otel_*
WHERE JSON_VALUE(jsonPayload.attributes, '$."prompt.id"') IS NOT NULL
GROUP BY prompt_id
)
SELECT *
FROM per_prompt
ORDER BY cost_usd DESC
LIMIT 20;
コストの高い順に並べて tool_failures が多いものを見ると、「エージェントが同じところで空回りしていた」プロンプトが浮かび上がります。ここがプロンプト改善や CLAUDE.md 整備の投資先になります。
⑤ サブエージェント・スキル・MCP の内訳
api_request には agent.name / skill.name / mcp_server.name / query_source が乗るので、コストを構成要素に分解できます。
SELECT
COALESCE(JSON_VALUE(jsonPayload.attributes, '$."agent.name"'), '(main)') AS agent,
JSON_VALUE(jsonPayload.attributes, '$.query_source') AS query_source,
COUNT(*) AS requests,
ROUND(SUM(CAST(JSON_VALUE(jsonPayload.attributes, '$.cost_usd') AS FLOAT64)), 4) AS cost_usd
FROM your-project.claude_code_otel.claude_code_otel_*
WHERE JSON_VALUE(jsonPayload.attributes, '$."event.name"') = 'api_request'
GROUP BY agent, query_source
ORDER BY cost_usd DESC;
サブエージェントは並列で走らせると便利ですが、その分コストも積み上がります。「便利だから」で多用していたものが実際いくらだったのかが、ここで数字になります。
分散トレース(beta)で1プロンプトの中身を見る
SQL で「どのプロンプトが高いか」を特定したら、次はその中身をトレースで追います。
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1
export OTEL_TRACES_EXPORTER=otlp
これでプロンプトから API 呼び出し・ツール実行までが span として繋がります。
claude_code.interaction ← ユーザープロンプト1回
├── claude_code.llm_request ← モデル呼び出し
├── claude_code.tool ← ツール実行
├── claude_code.llm_request
└── claude_code.tool
Cloud Trace のウォーターフォールで見ると、待ち時間がモデル推論なのかツール実行なのかが一目で分かります。「体感が遅い」の正体がテストの実行時間だった、というようなことが可視化されます。
!
beta 機能のため、仕様が変わる可能性があります。恒久的なダッシュボードを組むより、まずは調査用途で使うのが無難です。
ハマりどころ
1. BigQuery にデータが来ない
シンクは作成後のログにしか適用されません。また --log-filter が default_log_name と一致していないと何も流れません。まず Cloud Logging のコンソールで該当ログが見えているかを確認し、そこから下流を辿るのが早いです。
2. Collector は contrib 版が必要
googlecloud exporter はコアの Collector には入っていません。otelcol ではなく otelcol-contrib を使います。設定は validate サブコマンドで起動前に検査できます。
3. スキーマが想定と違う
ログシンクのテーブルは自動生成なので、属性の入り方が環境によって変わります。SQL を書く前に INFORMATION_SCHEMA.COLUMNS を見るのが確実です。日付シャードテーブルになるため、ワイルドカード _* での参照と _TABLE_SUFFIX での絞り込みを忘れるとスキャン量が膨らみます。
4. テレメトリ自体のコスト
Cloud Logging も BigQuery も従量課金です。エクスポート間隔を短くしたまま放置しないこと、シンクのフィルタを絞ることを意識してください。
実際に運用してみて
【要差し替え:実測値】
このセクションには、実際に自分の環境で1〜2週間回して得られた数字を入れてください。具体的には以下があると説得力が出ます。
期間中の総コストと、その内訳(モデル別 / サブエージェント別)
キャッシュヒット率の実測値
最も失敗率が高かったツールと、そのエラー種別
最もコストが高かったプロンプトの正体と、そこから何を改善したか
改善前後でのコスト・失敗率の変化
【ここまで要差し替え】
まとめ
Claude Code は OpenTelemetry をネイティブサポートしており、環境変数だけでメトリクス・イベント・トレースを出せる
googlecloud exporter で GCP に送り、Cloud Logging のシンク経由で BigQuery に着地させれば、使い慣れた SQL で分析できる
prompt.id による相関が強力で、「1つの指示にいくらかかったか」を単位にできる
ツールの失敗率は普段見えないが、そのままトークンの浪費になっている
AIエージェントは「便利だから使う」フェーズから「コストと品質を管理して使う」フェーズに移りつつあります。計測できないものは改善できないので、まずは可視化から始めるのが結局のところ近道でした。
データ基盤をやっている人間にとっては、送り先が BigQuery になった時点でいつもの仕事です。既存の SQL とダッシュボードの資産をそのまま流用できるのが、この構成の一番おいしいところだと思います。
!
GA4・BigQuery・Looker Studio・AI自動化の構築や設定代行を承っています(中小EC・個人事業主向け/スポット相談1万円〜)。「自社の場合はどうすれば?」のご相談も歓迎です。
ココナラでの販売実績は156件、評価は4.9をいただいています。
ウェブの便利屋(ろじかる)
ココナラからのご依頼はこちら → Claudeの活用法を一緒に設計します
After 9 months it merged T_T at this point it’s not just a PR.
Anyway it’s in opentelemetry’s go-build-tools, the thing every otel go project builds on :)
opentelemetry-go-build-tools#1552 t.co
27 views
GitHub ActionsとDockerのビルドを1本のトレースにしてGrafana Cloudで見る
!
2026年10月27日に、GitHubホストランナーのUbuntu 24.04で次のバージョンを使って確認した内容です。送信するシグナルはトレースだけです。
Docker Engine 28.0.4
Buildx v0.29.1
BuildKit v0.33.1
OpenTelemetry Collector contrib v0.164.0
GitHubレシーバーのトレース機能は、このバージョンではalphaです。後述する親スパンのID計算もレシーバーの実装に依存します。
はじめに
先日書いたDockerのビルドを遅くしたコミットをGrafana Tempoのトレースで見つけるでは、手元のビルドをトレースにして、遅くなったコミットやDockerfileの手順を調べました。次のステップはCIです。GitHub Actionsの画面で「Dockerのビルドが遅い」とわかっても、そのステップの中で何に時間がかかったかを調べるには、ビルドのログや記録を別に開く必要があります。ワークフロー全体からDockerfileの手順まで、同じトレースの中でたどれるようにしてみました。
TL;DR
BuildKitからGrafana CloudのOTLPエンドポイントへトレースを送信できることを確認しました。CIのステップにつなぐには、OpenTelemetry CollectorのGitHubレシーバーがステップに付けるIDをビルドの前に計算し、TRACEPARENTでBuildxへ渡します。CI側のスパンを送るのは、完了した実行の情報をGitHub APIで取得し、Actionsの中で一時的に動かすCollectorです。その結果、ワークフローと2つのジョブが1本のトレースにまとまり、buildジョブのビルドステップの下にBuildKitのスパンがつながりました。
2つの送信経路
今回の構成では、ビルドのトレースとGitHub Actionsのトレースを別々に送ります。両方でトレースIDと親スパンIDを合わせると、Grafana Cloudで1本に表示できます。
ビルド側の送信元は、OpenTelemetryでトレースを送る機能を持つBuildKitです。Dockerfileの各手順のスパンに加えて、クライアントであるBuildxのスパンも、BuildKitへ転送してまとめて送ります。
一方、GitHub Actions側には、BuildKitのようにトレースを送る送信元がありません。ワークフローの実行の記録はWebhookのイベントやREST APIで取得できますが、トレースの形式にはなっていないためです。この記録をスパンに変換するのが、OpenTelemetry CollectorのGitHubレシーバーです。GitHubレシーバーは、GitHubから届くworkflow_runとworkflow_jobのWebhookのイベントを受け取り、ワークフロー、ジョブ、ステップのスパンを作ります。レシーバーはCollectorに組み込んで動かすコンポーネントなので、GitHub Actions側のスパンはCollectorを通し、エクスポーターでGrafana Cloudへ送ります。
今回は、CI側のイベントの取得から送信までをActionsの中で実行する構成を選びました1。ワークフローの完了後に別のワークフローを起動し、そのランナーで一時的にCollectorを動かします。補助スクリプトが完了した実行の情報をGitHub APIで取得し、Webhookと同じ形式のイベントに組み立てて、ランナー内のGitHubレシーバーへ渡す構成です。BuildKitからの送信だけを試す段階では、このCollectorは使いません。
図1: 矢印はデータの移動を表します。左側では計算したTRACEPARENTをBuildxへ渡します。右側は完了後の別ワークフローで、補助スクリプトがGitHub APIの情報をイベント形式へ変換し、OpenTelemetry Collectorへ渡します。Collector内のGitHubレシーバーでCIスパンを生成して送信します。
題材はBusyBox 1.37.0の小さなイメージです。ファイルをコピーしてRUNで別ファイルへ書き出し、ビルドしたイメージを実行して出力も確かめました。実証用のファイル一式を公開しています。
BuildKitからGrafana Cloudへ
まず、Actionsの親スパンを付けず、BuildKitからの送信だけを確認しました。Grafana Cloudで取得した接続情報を、リポジトリのActionsシークレットに次の名前で登録します。
シークレット
設定する値
GRAFANA_CLOUD_OTLP_ENDPOINT
/otlpまでを含むOTLPのベースURL
GRAFANA_CLOUD_OTLP_HEADERS
Grafana Cloudの接続画面から取得したAuthorization=...の認証ヘッダー
Actionsのステップでは、これらをOTEL_EXPORTER_OTLP_ENDPOINTとOTEL_EXPORTER_OTLP_HEADERSという環境変数に渡しています。scripts/setup-builder.shの内容は次のとおりです。
#!/usr/bin/env bash
set -euo pipefail
: "${OTEL_EXPORTER_OTLP_ENDPOINT:?Missing OTLP endpoint}"
: "${OTEL_EXPORTER_OTLP_HEADERS:?Missing OTLP authentication headers}"
docker buildx create --name ci-otel --driver docker-container \
--driver-opt image=moby/buildkit:v0.33.1 \
--driver-opt env.OTEL_TRACES_EXPORTER=otlp \
--driver-opt env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf \
--driver-opt "env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT%/}/v1/traces" \
--driver-opt "env.OTEL_EXPORTER_OTLP_TRACES_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS}" \
--bootstrap >/dev/null
docker-containerドライバーではBuildKitが専用のコンテナで動くので、BuildKitのOTLP設定は--driver-opt env.…でそのコンテナの環境変数として渡します。プロトコルはhttp/protobufで、OTLP/HTTPを使います。トレース専用のエンドポイントは、ベースURLの末尾に/v1/tracesを足したURLです。
このbuilderを選んでdocker buildx build --builder ci-otel --load …を実行すると、BuildxとBuildKitの両方のスパンがGrafana Cloudへ届きました。ベースラインの実行から取得したのは、build .をルートにした87スパンのトレースです。イメージのビルドと実行テストも成功しました。
図2: Grafana Cloudで取得したベースラインのトレース。BuildxとBuildKitの87スパンを確認できます2。
Actionsのステップを親にする
ここまでのトレースのルートはbuild .です。GitHub Actionsのビルドステップを親にするには、Buildxを実行するステップに環境変数TRACEPARENTを設定します。Buildxは環境変数からトレースコンテキストを読み込む処理でこの値を取り込み、build .を指定された親の子にします。TRACEPARENTに入れるのは、接続先のトレースIDと、親になるステップのスパンIDです。
ただし、ビルドの時点では親になるステップのスパンはまだありません。CI側のスパンは、ワークフローの完了後に作るからです。それでも親のIDを指定できるのは、GitHubレシーバーがトレースIDとスパンIDを乱数ではなく実行IDやジョブIDから決まった規則で計算するためです。ビルドの前に同じ規則で計算すれば、あとで届くステップのスパンと同じIDが得られます。
補助スクリプトscripts/ci-parent.pyは、今回の実行ID、再実行回数、ジョブIDから、レシーバーのID生成の実装と同じ規則でIDを計算します3。計算部分を抜粋すると次のとおりです。
trace_id = hashlib.sha256(f"{run}{attempt}t".encode()).hexdigest()[:32]
step_id = hashlib.sha256(f"{job_id}-Build Docker image-s".encode()).hexdigest()[16:32]
traceparent = f"00-{trace_id}-{step_id}-01"
TRACEPARENTの値は、W3C Trace Contextのtraceparentヘッダーと同じ00-トレースID-親スパンID-01の形式です。スクリプトは、この値をGITHUB_OUTPUTへ書き出します。
ワークフローでは、このスクリプトの出力をビルドステップだけに渡します。build.ymlから該当部分を抜粋すると、次のようになります。
- name: Calculate build step parent
id: build_context
env:
GH_TOKEN: ${{ github.token }}
run: python3 scripts/ci-parent.py
- name: Build Docker image
env:
TRACEPARENT: ${{ steps.build_context.outputs.traceparent }}
OTEL_RESOURCE_ATTRIBUTES: vcs.ref.head.revision=${{ github.sha }},ci.run.id=${{ github.run_id }},ci.run.attempt=${{ github.run_attempt }},ci.repository=${{ github.repository }},experiment.mode=linked
run: docker buildx build --builder ci-otel --load --tag buildkit-otel-spike:ci .
ステップのスパンIDはジョブIDとステップ名から決まるので、ステップ名はスクリプトの計算に使ったBuild Docker imageと一致させます。また、ステップ名はジョブ内で一意にしておきます。同じジョブに同名のステップが2つあると、レシーバーが両方に同じスパンIDを付けてしまうからです。
OTEL_RESOURCE_ATTRIBUTESでは、検索に使う実行IDやコミットを付けています。この属性が付くのはBuildxのスパンだけで、BuildKitのスパンには付きません。
CI側のスパンの送信
ビルドステップが終わっても、ワークフロー全体はまだ実行中です。CI側のスパンは、workflow_runのcompletedで起動する送信用のワークフローが作ります。
送信用のワークフローは、完了通知に含まれる実行IDと再実行回数を環境変数で補助スクリプトへ渡します。
OBSERVED_RUN_ID: ${{ github.event.workflow_run.id }}
OBSERVED_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
再実行回数を指定するのは、取得した時点で最新の再実行ではなく、通知された回のジョブを読むためです。補助スクリプトは、その回のジョブとステップの情報をGitHub APIで取得し、Webhookと同じ形式のイベントに組み立ててランナー内のCollectorへPOSTします。変換の処理はscripts/export-workflow.pyで確認できます。
collector.yamlの内容は次のとおりです4。
receivers:
github:
webhook:
endpoint: 0.0.0.0:19418
path: /events
health_path: /health
service_name: github-actions
scrapers:
scraper:
github_org: ymotongpoo
processors:
batch:
timeout: 1s
exporters:
otlp_http/cloud:
endpoint: ${env:OTEL_EXPORTER_OTLP_ENDPOINT}
headers:
Authorization: ${env:GRAFANA_AUTHORIZATION}
timeout: 10s
sending_queue:
enabled: false
retry_on_failure:
enabled: false
service:
telemetry:
logs:
encoding: json
pipelines:
traces:
receivers: [github]
processors: [batch]
exporters: [otlp_http/cloud]
エクスポーターのendpointはベースURLのままです。otlp_httpのエクスポーターが/v1/tracesを付けるので、BuildKitにトレース専用のURLを渡したのとは指定方法が違います。認証ヘッダーには、BuildKitと同じシークレットからAuthorization=の後ろの値を取り出して渡します。
このCollectorは送信のたびに起動して停止する実証用の設定で、送信キューと再試行を無効にしています5。
自分の環境で試すには、2つのシークレットを登録し、ビルド用と送信用のワークフローをデフォルトブランチに置きます。そのうえでBuildKit linked traceを手動で実行し、ビルドとExport completed workflow traceの両方の完了を待ちます。APIの呼び出しに使うのはcontents: readとactions: readの権限を付けたGITHUB_TOKENで、GitHub AppやPATは不要です。
1本につながった結果
ビルド側の実行とCI側のスパンの送信は、どちらも成功しました。ワークフロー全体とビルドの内部が1本のトレースにつながっています。
図3: 同じ実行のワークフロー、2つのジョブ、各ステップが1本のトレースになりました。Build Docker imageの下にbuild .が接続しており、ビルド内部は折りたたんでいます。
サービスごとの内訳は次のとおりでした。
service.name
スパン数
記録している処理
github-actions
21
ワークフロー、ジョブ、待ち時間、ステップ
buildx
33
ビルドクライアントの処理
buildkitd
54
ビルドデーモンとDockerfileの手順
合計
108
同一トレースIDのスパン
ビルド用のbuildと、その完了後に別のランナーで実行するverify-sourceの2ジョブが、同じルートにつながっています。CIの待ち時間やビルド内部の細部を省くと、親子関係は次のようになります。
BuildKit linked trace
├── build
│ └── Build Docker image
│ └── build .
│ └── …
│ └── [4/4] RUN cat message.txt > result.txt
└── verify-source
└── Verify source fixture
build .の親IDはBuild Docker imageのスパンIDと一致し、取得した108スパンはすべて同じワークフローのルートへつながっていました6。
GrafanaのExploreでTempoのデータソースを選び、実行した時間帯に絞って、次のTraceQLで検索します。ci.run.idは、OTEL_RESOURCE_ATTRIBUTESでBuildxのスパンに付けた属性です。
{ resource.ci.run.id = "40437818776" && name = "build ." }
前回の記事では、ルートのスパンだけを選ぶ条件(span:parentID = "")でビルドの所要時間を集計しました。今回のビルドはbuild .にActionsの親があるので、この条件では選べません。手元のビルドとCIのビルドをまとめて選ぶ条件は、{ resource.service.name = "buildx" && name = "build ." }です。ベースラインとCIの両方のトレースが、この条件の検索結果に入ることを確認しました。
CI側のスパンは、送信用のワークフローが完了してから届きます。トレースを開くのは、両方のワークフローが完了してからにしてください7。
今回確認した範囲
BuildKitからGrafana Cloudへの送信と、2ジョブのワークフローへのビルド内部の接続までを確認しました。ただし、IDの生成規則はOpenTelemetryの共通の仕様ではなく、GitHubレシーバー v0.164.0の実装です。Collectorのバージョンやレシーバーのfeature gateを変えるときは、取得したトレースでbuild .が親のステップにつながっているかを確かめ直してください。
CI側の時刻はGitHub APIが秒単位で記録した値です。そのため、親子の接続が正しくても、BuildKitのスパンが親の時間範囲からわずかにはみ出すことがあります。今回の実行では、build .の終了が親のBuild Docker imageの終了を約0.84秒超えていました。
今回は信頼できるリポジトリでの手動実行だけで、matrix、reusable workflow、forkからのPR、実際の再実行、実行間でのキャッシュ再利用は検証していません8。
おわりに
GitHub Actionsのワークフローとステップから、Dockerfileのどの手順やレジストリへの通信に時間を使ったかまで、1本のトレースでたどれるようになりました。自分のCIへ取り込むときには、まずBuildKitの送信だけを確認し、必要に応じてCIの親スパンを追加する順で試せます。
脚注
GitHubレシーバーは、常時稼働するCollectorでGitHubからのWebhookを受け取る使い方を想定しています。組織のCIを継続して記録するなら、その構成のほうが向いています。常時稼働のCollectorなら、送信キューと再試行を有効にして、Grafana Cloudへの送信に失敗したスパンを再送することもできます。ただし、GitHubからWebhookを受けるために、インターネットから届くエンドポイントを用意して運用する必要があります。今回はその準備をせずに試すため、Actionsの中で完結させました。 ↩︎
図の上部に赤く表示されているHEAD HTTP HEADのエラーは、Docker Registryのトークン認証で最初に返る401の認証要求です。今回の実行では、トークン取得後の再試行が200となり、ビルドも成功しています。 ↩︎
ジョブの数値IDは、GitHub APIで今回の実行のジョブの一覧を取得し、名前がGITHUB_JOBと一致するジョブから取り出します。ジョブの表示名を変えたりmatrixを使ったりする場合は、別の方法でジョブを特定する必要があります。 ↩︎
v0.164.0のGitHubレシーバーは、トレースだけを使う場合でも、scrapersを1つ以上設定することを求めます。パイプラインはトレースだけなので、github_orgを使ったメトリクスの収集は動きません。 ↩︎
Collectorはbatchプロセッサーを通して送るので、レシーバーがHTTP 200を返しても、Grafana Cloudへの送信が成功したとは限りません。そこで補助スクリプトは、停止後のCollectorがJSON形式で出力したログを読み、levelがエラーを示していれば失敗として扱います。 ↩︎
検証時のスクリプトと検証記録を公開しています。取得した108スパンは、スパンIDがすべて一意で、ルートが1つ、親が見つからないスパンは0でした。この検査では、送信したすべてのスパンがGrafana Cloudに届いたかまでは判定できません。 ↩︎
Tempoの最初の検索結果では、ルートのサービス名が<root span not yet received>と表示されました。その後にトレースIDで取得すると、CI側を含む108スパンがそろっていました。 ↩︎
送信用のワークフローは、元の実行のコミットにある補助スクリプトを実行します。外部のPRが起点になる構成へ広げるときは、GitHubが説明するworkflow_runの権限と注意点を確認してください。 ↩︎
He co-leads the OTel Injector and Packaging SIGs.
The Injector switches on auto-instrumentation at process start for Java, Node.js, .NET and Ruby, with no code changes.
open-telemetry/opentelemetry-injector
20 views
OpenTelemetry の Semantic conventions を眺めていたら よくみている db.statement や db.connection_string が deprecated になっていたことに気づいてしまった。changelog を見る感じだと1.25 や 1.26 で大きく手が入ったぽいな。opentelemetry.io
github.com/open-telemetry/semantic-conventions/bl…
7 likes · 1 repost · 269 views
Mentions
OpenTelemetry Collector Custom Builds: 6.5x Smaller, and What Resource Detectors Cost at Startup --- title: OpenTelemetry Collector Custom Builds: 6.5x Smaller, and What Resource Detectors Cost at Startup published: true date: 2026-11-05 15:00:00 UTC tags: DevOps,OpenTelemetry,Observability,Go canonical_url: devops-daily.com --- The contrib release of the OpenTelemetry Collector 0.164.0 is a 420.3 MB binary on linux/amd64. We built a Collector with the OpenTelemetry Collector Builder (ocb) that has only the 10 components a VM agent uses and only the env and system resource detectors. It is 64.3 MB, so contrib is 6.5 times larger. After 10 seconds idle, the custom build held 45.1 MiB of resident memory and contrib held 199.8 MiB. The anonymous part (heap, stacks and runtime data) was 8.0 MiB against 34.8 MiB. The binary adds a little to startup: contrib needed 110 to 160 ms more than the small build on the droplet, and 300 to 500 ms more on the Pi. The detector list in your config can add seconds. We started both builds with cloud detectors for clouds the host was not on, in four network setups. In every list without akamai, each start waited for the slowest detector. When that detector got no answer, it waited for the whole timeout, which is 5 s by default. That happened on a Raspberry Pi with no metadata service. With a list of 13 cloud detectors it also happened on a DigitalOcean droplet, because two of them ask addresses that DigitalOcean does not answer. The detectors run in parallel, so where nothing answered, the list with 3 cloud detectors and the list with 13 cost the same 5 s. timeout: 1s cut the wait to about 1 s in most cases. Two detectors do not obey it. digitalocean ignores the deadline, so where nothing answers it waits its own 2 s. akamai makes its network call while the processor is created. In all four setups we tested, none of them on Akamai, the Collector did not start. It exited at once where the request got an answer or a fast error, and after 30 s where it got no answer. On 29 October the OpenTelemetry blog showed how to compile detectors out with ocb and reported a Kubernetes distribution about 20% smaller. This post measures three things that page does not cover. What does the same filter do to a small build? What does the build do to memory at idle? And what does the detector list cost at startup on hosts that are not on the clouds the config names? Every number below comes from a file in the companion repo. TL;DR - Size. On amd64, contrib is 420.3 MB, the core otelcol release is 183.8 MB, our agent with every detector is 124.1 MB, and the same agent with only env and system is 64.3 MB. On this small build, the detector filter alone removed 48% of the binary and 64 Go modules: the AWS SDK, the Google Cloud clients, the Docker client, the Consul API and four more cloud metadata clients. arm64 gives the same picture. - Memory. After 10 s idle: contrib 199.8 MiB RSS, core 98.4 MiB, our agent 61.5 MiB, the agent without cloud detectors 45.1 MiB. About four fifths of each number is file-backed memory (RssFile), which grows with the binary. The anonymous part (RssAnon) was 34.8 MiB for contrib and 8.0 MiB for the small build. - Startup with no cloud detectors: about 190 to 220 ms for contrib and 45 to 85 ms for the small agents on the droplet, and 480 to 610 ms against 110 to 270 ms on the Pi. - Startup with cloud detectors for other clouds: [env, system, ec2, gcp, azure] held startup for the full 5 s timeout where 169.254.169.254 got no answer. Where the connect failed at once, it held startup for 4.1 s, because the AWS SDK retries the connect. On the droplet those three cost under 4 ms each, but the 13-cloud list still took 5.1 s there, because alibaba_ecs and scaleway ask other addresses. - The timeout setting bounds the wait: 1 s, 5 s or 10 s plus normal startup. The exceptions are digitalocean, which waits its own 2 s whatever you set when nothing answers, and akamai. - No start at all. akamai in the list: exit 1 in all four setups, after 30 s in the two with no answer. fail_on_missing_metadata: true: exit 1 after the 5 s timeout. A detector that ocb compiled out but the config still names: validate passes, then a nil pointer panic at start. The processor's name for a detector in the ocb manifest (ec2 instead of aws_ec2): the build stops. - What to do. List only the detectors for the platform the host runs on, and build with the same list in dist.resource.detection.detectors. If one config must cover several clouds, keep the list short, set timeout to 1 or 2 s, and leave out akamai. Prerequisites - An x86_64 Linux host for the builds, with about 3 GB of free disk. ocb cross-compiles the arm64 binaries there. - curl, python3 and ip (iproute2). Root only for the two network namespaces. - An arm64 Linux host if you want the second architecture. We used a Raspberry Pi with 4 cores and less than 4 GB of memory. - About an hour for a full run. The cases that fail after 30 s take most of it. What we built and where we ran it We downloaded the official 0.164.0 releases of otelcol-contrib and otelcol for both architectures and checked each tarball against its published .sha256. Then ocb 0.164.0 built two versions of one small agent. It has the components a VM agent uses: - receivers: OTLP, hostmetrics and filelog - processors: batch, memory_limiter and resource_detection - exporters: debug, OTLP and OTLP/HTTP - extension: health_check otelcol-agent uses the ocb default, so every detector is compiled in. otelcol-agent-envsys adds the ocb setting that compiles out every detector you do not list: # manifests/agent-envsys.yaml (excerpt, comment on detectors added) dist: name: otelcol-agent-envsys version: 0.164.0 build_tags: 'grpcnotrace' # same tag as the contrib release manifest resource: detection: detectors: # ocb 0.163.1 and later: every detector not listed gets an omit_detector_<name> build tag - env: - system: go version -m on the release binaries reports go1.26.10, so we pinned the same Go for our builds. go version -m and file show -trimpath=true, CGO_ENABLED=0 and stripped symbols for every binary (results/buildinfo/). Every start ran the same pipeline: OTLP in, batch, debug out. The harness gave each start a clean environment with only PATH and an empty HOME. No OTEL_* or AWS_* variable and no ~/.aws file could change what a detector found. The Pi runs also set TZ=UTC, so their logs use UTC like the droplet's; the droplet runs used an earlier revision of the harness without it. Only the resource_detection block changed between configs. Each case ran 5 times in a shuffled order, after one warm-up start per binary that we left out of the numbers. - DigitalOcean droplet 4 vCPU, 8 GB, amd64 - host network metadata server answers - blackhole namespace packets dropped - noroute namespace connect fails at once - Raspberry Pi 4 cores, under 4 GB, arm64 - home network router drops 169.254.169.254 The four network setups matter more than the two architectures: | Setup | What a request to 169.254.169.254 gets | | --- | --- | | Droplet host | DigitalOcean's metadata server answers. The EC2 and Azure paths return 404 in about 1 ms. This is the "host on another cloud" case. | | Blackhole namespace | The default route goes to a dummy device, which discards every packet, so a connect gets no answer. DNS fails at once, because nothing listens on 127.0.0.53 inside the namespace. | | Noroute namespace | Loopback only. A connect fails at once with "network is unreachable". | | Raspberry Pi | The home router drops the packets. DNS works. This is the plain "not on any cloud" host. | The-DevOps-Daily/otelcol-ocb-vs-contrib on GitHub Size: what ocb removes Binary size, linux/amd64, OpenTelemetry Collector 0.164.0 | | Value | Series | | --- | --- | --- | | otelcol-contrib (release) | 420.3 MB | release | | otelcol core (release) | 183.8 MB | release | | agent, all detectors | 124.1 MB | custom | | agent, env + system only | 64.3 MB | custom | _results/sizes.tsv. Stripped, -trimpath, CGO_ENABLED=0, go1.26.10. The two agents have the same 10 components; only the compiled-in detectors differ._ The contrib manifest lists 246 components and 9 config providers, and the binary links 1,088 Go modules. The core release is not small either. Its 30 components include the Kafka and Prometheus receivers and exporters, and it also ships resource_detection with every detector. Our agent with every detector links 289 modules, and the detector filter takes it to 225. The filter saves more on a small build than on a big one. The OpenTelemetry blog measured the Kubernetes distribution and got 182 MB to 147 MB with 13 fewer dependencies. On our agent it removed 59.8 MB of 124.1 MB (48%) on amd64, 52.9 MB of 112.7 MB (47%) on arm64, and 64 modules. A big distribution needs most of those SDKs for other components, so removing the detectors does not remove the SDKs. In a small agent, nothing else needs them. results/deps/removed-by-detector-filter-amd64.txt lists the 64: 15 aws-sdk-go-v2 and smithy-go modules, the Google Cloud compute, auth and API clients, the Docker (moby) client, the Consul API with its HashiCorp libraries, and the Hetzner, Scaleway, Linode and DigitalOcean clients. For containers, the official otel/opentelemetry-collector-contrib:0.164.0 image is 116.1 MB compressed on amd64 (results/images.tsv). We did not build an image of the small agent. Its binary compresses to 20.3 MB with gzip -6, so a FROM scratch image would be near that, but that is an estimate, not a measurement. One build difference favours our side. The contrib manifest asks for the grpcnotrace build tag and our builds use it, but the release binaries record no build tags in go version -m. We built otelcol-agent-notag to put a size on it: 124.23 MB against 124.11 MB, so the tag is worth 0.1%. Memory at idle Resident memory after 10 s idle, droplet, amd64 | | Value | Series | | --- | --- | --- | | contrib: RssAnon | 34.8 MiB | RssAnon | | contrib: RssFile | 165.2 MiB | RssFile | | core: RssAnon | 18.7 MiB | RssAnon | | core: RssFile | 79.5 MiB | RssFile | | agent, all detectors: RssAnon | 11.2 MiB | RssAnon | | agent, all detectors: RssFile | 50.2 MiB | RssFile | | agent, env + system: RssAnon | 8 MiB | RssAnon | | agent, env + system: RssFile | 37 MiB | RssFile | _Median of 5 starts, /proc//status, config without resource_detection, results/summary.md. RssFile is resident file-backed memory; RssAnon is resident anonymous memory (heap, stacks, runtime)._ Resident memory follows the binary size, but not one to one. Contrib held 199.8 MiB, 4.4 times the 45.1 MiB of the small agent. About four fifths of every number is RssFile, resident memory backed by files. It grows with the binary: 165.2 MiB for contrib, 37.0 MiB for the small agent. That points at the binary's own pages, but we did not map them one by one. The kernel can drop clean file-backed pages under memory pressure and read them again later. RssAnon is anonymous memory, which it cannot drop without swap. There contrib held 34.8 MiB against 8.0 MiB. A Go program runs the init code of every package it links before main, so a likely part of that difference is setup work for code that this pipeline never uses. We did not profile the heap to prove it. Detection with [env, system] added little: contrib went to 205.4 MiB and the small agent to 47.8 MiB. The Raspberry Pi gave the same order on arm64: contrib 183.8 MiB with 34.7 MiB anonymous, the small agent 40.6 MiB with 8.0 MiB. We did not test what happens to RssFile under memory pressure, or how a cgroup memory limit counts it. Read the RssAnon column as the floor and VmRSS as what top shows you. Startup: the slowest detector sets the time Median time from exec to the log line "Everything is ready", 5 starts each, from results/summary.md. Each cell is the small agent with every detector, then contrib in brackets: | Config | Droplet host | Blackhole namespace | Noroute namespace | Raspberry Pi | | --- | --- | --- | --- | --- | | [env, system] | 79 ms (189 ms) | 77 ms (210 ms) | 83 ms (221 ms) | 192 ms (481 ms) | | + ec2, gcp, azure | 81 ms (196 ms) | 5,088 ms (5,203 ms) | 4,098 ms (4,242 ms) | 5,172 ms (5,486 ms) | | + 13 VM clouds, no akamai | 5,090 ms (5,213 ms) | 5,086 ms (5,203 ms) | 4,087 ms (4,227 ms) | 5,167 ms (5,393 ms) | | + akamai | exit 1 after 0.1 s (0.2 s) | exit 1 after 30.1 s (30.2 s) | exit 1 after 0.1 s (0.2 s) | exit 1 after 30.2 s (30.5 s) | Without a network wait, contrib needs about 110 to 160 ms more than the small agent to start on the droplet, and about 300 to 500 ms more on the Pi. That is real but small, and it matters only if you start collectors very often, for example one per short CI job. The wait comes from three things in the processor source (processor/resourcedetectionprocessor at v0.164.1): 1. Detectors run in parallel. detectResource starts one goroutine per detector and then reads their results in order (internal/resourcedetection.go, lines 203 to 222). The wait is the slowest detector, not the sum. 2. The whole detection has one deadline: timeout. The default is 5 s (factory.go, lines 153 to 157). With the default retry.max_elapsed_time: 0, Refresh puts that timeout on the context that every detector gets (internal/resourcedetection.go, lines 159 to 168). 3. Startup waits for it. The processor's Start runs the first detection and returns its error (resourcedetection_processor.go, lines 36 to 40). The Collector logs "Everything is ready" only after every processor has started. The processor also records how long each detector took, in its own otelcol_resourcedetection_detector_duration histogram. We read it from the Collector's Prometheus endpoint after each start. This is the 13-cloud list in the blackhole namespace: Time per detector, 13 VM cloud detectors, blackhole namespace, timeout 5s | | Value | Series | | --- | --- | --- | | ec2 | 5000.7 ms | waits for timeout | | alibaba_ecs | 4999.8 ms | waits for timeout | | azure | 4999.7 ms | waits for timeout | | hetzner | 5000.2 ms | waits for timeout | | scaleway | 4999.6 ms | waits for timeout | | digitalocean | 2000.6 ms | own 2 s timeout | | nova | 2000.4 ms | own 2 s timeout | | upcloud | 2000.7 ms | own 2 s timeout | | vultr | 2000.8 ms | own 2 s timeout | | oraclecloud | 200.7 ms | fast | | ibmcloud_vpc | 1.2 ms | fast | | gcp | 0.9 ms | fast | | tencent_cvm | 0.7 ms | fast | _Median of 5 starts of the small agent with all detectors, from otelcol_resourcedetection_detector_duration in results/runs/droplet-blackhole-extra.jsonl. Every detector reported outcome=success: "not on this cloud" is a success with no attributes._ The same histogram tells you which detector held each of the other setups: | Setup | Detectors that took the longest | | --- | --- | | Droplet host | alibaba_ecs and scaleway, 5.0 s each. They ask 100.100.100.200 and 169.254.42.42, and nothing on DigitalOcean answers those. The other 11 finished in 2 to 20 ms (medians). | | Blackhole namespace | Five detectors at 5.0 s and four at 2.0 s (see the chart). | | Noroute namespace | ec2 alone, 4.0 s. Every other detector finished in under 2 ms. | | Raspberry Pi | Six detectors at 5.0 s: the five from the blackhole namespace and ibmcloud_vpc, which took 1.2 ms in the namespace. We did not trace why. The same four at 2.0 s. With working DNS, gcp took 13.5 ms and tencent_cvm 15.9 ms. | How each detector probes decides how long it waits: - azure sends one GET with a plain &http.Client{} that has no timeout (internal/metadataproviders/azure/metadata.go, lines 33 to 38). When the packets get no answer, it waits for the processor's deadline. Then it returns an empty resource and no error (internal/azure/azure.go, lines 55 to 63). - ec2 uses the AWS SDK's IMDS client: a 250 ms dial timeout, a 500 ms response header timeout (aws-sdk-go-v2 feature/ec2/imds v1.20.1, api_client.go, lines 277 and 278), and up to 3 attempts per request (internal/aws/ec2/config.go, lines 16 to 19). A connect that fails at once is retried with backoff, which is why ec2 took 4.0 s in the noroute namespace. With no answer at all and a 10 s timeout, it gave up by itself after 5.5 s. - gcp calls metadata.OnGCEWithContext, which races a DNS lookup of metadata.google.internal against an HTTP probe. On a host that does not look like a Google VM, it trusts the first answer (cloud.google.com/go/compute/metadata v0.10.0, metadata.go, lines 456 to 480). A fast NXDOMAIN or a fast DNS failure ends it. - digitalocean ignores the context: its Detect takes _ context.Context (internal/digitalocean/digitalocean.go, line 51), and the DigitalOcean metadata client has a fixed 2 s timeout (digitalocean/go-metadata, client.go, lines 25 and 64). We started from this claim: on a host that is not on EC2, Google Cloud or Azure, each cloud detector adds a wait at startup. Two results would prove it wrong: no wait at all, or the same wait for 3 detectors and for 13. We got the second, so it was half wrong. The detectors do not each add a wait. During detection they overlap, and the slowest one sets the time. But a host on a cloud with a metadata server is not safe either, because not every cloud uses 169.254.169.254. The timeout setting Start to "Everything is ready" with [env, system, ec2, gcp, azure] | | timeout: 1s | timeout: 5s (default) | timeout: 10s | | --- | --- | --- | --- | | contrib, blackhole namespace | 1238.8 ms | 5203.1 ms | 10235.4 ms | | agent, blackhole namespace | 1077.1 ms | 5087.6 ms | 10085.4 ms | | contrib, Raspberry Pi | 1526.7 ms | 5485.5 ms | 10710.8 ms | | contrib, noroute namespace | 1240.1 ms | 4241.6 ms | 4229.5 ms | | contrib, droplet host | 209.3 ms | 196 ms | 216 ms | _Median of 5 starts per point, results/summary.md. The droplet host answers on 169.254.169.254; the blackhole namespace and the Pi do not. The noroute namespace fails the connect at once._ Where nothing answers, the wait is the timeout plus normal startup, on both architectures. In the noroute namespace it stops at 4.1 to 4.2 s, where ec2 gives up. timeout: 1s brought every three-cloud case that waited down to 1.1 to 1.2 s. The 13-cloud list with timeout: 1s did not get there. In the blackhole namespace it started in 2,097 ms (contrib 2,234 ms), and on the Pi in 2,175 ms (contrib 2,410 ms), because digitalocean took its 2 s anyway. A short timeout has a cost that we could not measure. On a real EC2, Google Cloud or Azure VM, a detector that needs more than 1 s gets cut off. It returns an empty resource with no error, and your telemetry has no cloud attributes. We did not run on those clouds, so we cannot say whether 1 s is enough there. Most examples in the processor README use timeout: 2s. Four ways the Collector does not start 1. ocb build unknown detector name: stops 2. otelcol validate passes a compiled-out detector 3. create processor akamai fetches a token 4. Start: detection parallel, one deadline 5. Everything is ready 1. The akamai detector Of the 14 VM cloud detectors we ran, akamai is the only one that waited or failed while the processor was created. Its NewDetector calls newAkamaiClient(context.Background()) (internal/akamai/akamai.go, lines 43 to 57), and the Linode metadata client fetches a token inside NewClient (linode/go-metadata v0.3.1, client.go, lines 88 to 91). This happens before detection, so timeout does not apply, and a failure there is a failure to build the pipeline. On the droplet, DigitalOcean's metadata server answers the token request, but not with a token, and the Collector exits at once: results/runs/droplet-host-extra.jsonl, otelcol-contrib, configs/akamai.yaml $ bin/linux-amd64/otelcol-contrib --config configs/akamai.yaml Error: failed to build pipelines: failed to create "resource_detection" processor, in pipeline "traces": failed creating detector type "akamai": failed to refresh metadata token: failed to generate metadata token: no token returned from API # exit code 1, 0.2 s after exec In the noroute namespace it exited at once with connect: network is unreachable. In the blackhole namespace and on the Pi, the token request first waited for a 30 s dial timeout. The Collector exited after 30.1 s in the namespace and 30.2 s on the Pi (contrib: 30.2 s and 30.5 s). With timeout: 1s it still took 30 s. 2. fail_on_missing_metadata: true This setting makes a cloud detector return an error when its metadata service is missing, instead of an empty resource. Retry is on by default (factory.go, lines 142 to 151), so the processor retries the detector with backoff until timeout, and then Start returns the error. With [env, system, ec2, gcp, azure], the Collector exited with code 1 after 5.1 to 5.3 s in all three droplet setups. The config comment says the setting is for nodes that boot before their metadata service is ready (config.go, lines 63 to 70). It also turns "not on EC2" into "does not start". 3. A detector that ocb compiled out This is the trap in the new ocb feature. otelcol-agent-envsys has only env and system. We gave it the three-cloud config: results/break/amd64-validate.txt and results/break/amd64-start.txt $ bin/linux-amd64/otelcol-agent-envsys validate --config configs/clouds3.yaml exit code: 0 $ bin/linux-amd64/otelcol-agent-envsys --config configs/clouds3.yaml panic: runtime error: invalid memory address or nil pointer dereference [signal SIGSEGV: segmentation violation code=0x1 addr=0x18 pc=0x130a757] goroutine 57 [running]: github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal.(*ResourceProvider).detectWithRetry.func2() github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor@v0.164.1/internal/resourcedetection.go:314 +0xb7 ... exit code: 2 In v0.164.1 each *_omit.go stub returns nil, nil from NewDetector, for example internal/aws/ec2/ec2_omit.go. The processor keeps the nil detector and calls it. This is issue #52519, open when we wrote this. On main, #52063 changes the stubs so they register no factory. It was merged after v0.164.1. With it, the processor returns detector "ec2" is not compiled into this binary with the list of compiled detectors. Until a release has that change, validate does not catch the mismatch, and the first sign is a crash. 4. The processor's names in the ocb manifest ocb and the processor do not use the same detector names. The processor config says ec2, nova and aks. The ocb manifest wants the build tag names aws_ec2, openstack_nova and azure_aks (cmd/builder/internal/builder/config.go, lines 117 to 151). This one fails early, which is the good kind of failure: results/build/agent-badname-amd64.log $ ocb --config manifests/agent-badname.yaml Error: invalid configuration: ec2: unknown resource detector What to put in the manifest and the config Make the two lists match, each in its own names: # ocb manifest: build tag names dist: resource: detection: detectors: - env: - system: - aws_ec2: # the processor calls this one "ec2" # Collector config: processor names, the same detectors processors: resource_detection: detectors: [env, system, ec2] timeout: 2s # the most a missing metadata service can add to startup - Use one list per platform. A fleet on EC2 does not need azure, and a fleet on bare metal needs neither. - If one config must cover several clouds, keep the list short and set timeout to 1 or 2 s. Expect digitalocean to add 2 s, whatever the timeout, on hosts where its request gets no answer. - Leave akamai out unless the hosts run on Akamai Cloud. - Keep fail_on_missing_metadata off unless "no metadata" should mean "do not start". - Use the name resource_detection. In 0.164 the old name resourcedetection still works, with a deprecation warning (results/break/amd64-legacy-name.txt). - Until the #52063 change is in a release, start each new custom build once with its real config before you ship it. validate is not enough. What we could not conclude - On a real AWS, Google Cloud or Azure VM. We had no instances there, so we did not measure how long a detector takes when it succeeds, or whether a 1 s timeout cuts it off. - The container image of the small agent. We report the compressed size of the official image and the gzip size of our binary. We did not build or push an image. - Memory under pressure. RssFile can be reclaimed. We did not measure how much the kernel takes back, or how a cgroup limit counts it. - Small startup differences. Each case has 5 starts. The 110 to 160 ms between contrib and the small agent repeats in every droplet setup. The Pi also ran other work, and three of its starts took 4.6 to 22 s. In all three, the time went before the Collector wrote its first log line, which points at reading the binary from the SD card, not at detection. We did not confirm that. They move the medians little, but they are in the min and max columns of results/summary.md. - Slow DNS. DNS failed at once in the namespaces and answered on the Pi, so gcp and tencent_cvm were fast everywhere. A host whose DNS times out would make them wait too. We did not test that. - Other versions. Everything here is 0.164.0 and 0.164.1. The akamai and omit behaviour may change in the next release. Related reading: - Distributed tracing with OpenTelemetry runs a Collector end to end. - OpenTelemetry graduated covers which vendor agents a Collector can replace. - Trace context in environment variables follows a trace through a CI pipeline. Summary A custom Collector with only the components you use is 6.5 times smaller than contrib and holds under a quarter of its memory. On a small build, compiling out the unused detectors is half of the size saving. The binary costs tens to hundreds of milliseconds at startup. The detector list can cost seconds. Apart from akamai, the cloud detectors you list for clouds the host is not on share one wait: the slowest probe. Where that probe gets no answer, it usually runs to the full timeout, even on a host on another cloud. digitalocean waits its own 2 s instead, even past a shorter timeout. Keep the list to the platforms you run on, and build with the same list. Set a short timeout if the list must stay long. Start each new binary once with its real config. Leave akamai out unless you run on Akamai. _Originally published at devops-daily.com._
Rust Observability in 2026: OTLP Logs and Metrics Are Stable—What About Traces? --- title: "Rust Observability in 2026: OTLP Logs and Metrics Are Stable—What About Traces?" published: true description: "OpenTelemetry Rust 0.33.1 stabilizes OTLP log and metric exporters. Here's what that means—and why tracing is still beta." tags: rust, opentelemetry, observability, devops --- Rust has a strong observability ecosystem built around tracing, but getting its events into an observability backend involves a few distinct pieces: spans, log records, metrics, and exporters. There is a useful milestone in the current OpenTelemetry Rust release that makes this setup more interesting: as of OpenTelemetry Rust 0.33.1, the OTLP exporters for logs and metrics are stable, while the OTLP exporter for traces remains in beta. That distinction is easy to miss if we just say “OpenTelemetry is stable.” Let’s look at what changed and how it maps to a real Rust service. What became stable? The OpenTelemetry Rust 0.33.1 release, published on October 8, 2026, declared the OTLP exporters for Logs and Metrics stable after a release-candidate period. The Traces exporter remains beta. | Signal | OTLP exporter status in 0.33.1 | | --- | --- | | Logs | Stable | | Metrics | Stable | | Traces | Beta | This is specifically about the OTLP exporters in the Rust implementation—not a blanket statement that every OpenTelemetry crate or API has the same stability guarantee. The release notes also describe the Logs and Metrics APIs and SDK as stable, while Distributed Tracing remains pre-stable. There is another useful reliability improvement: starting in 0.33.0, OTLP retries are enabled by default for HTTP and gRPC exporters. The default policy uses exponential backoff and jitter, with up to three retries after the first attempt. In other words, a temporary collector hiccup no longer necessarily means an export is immediately lost. tracing events can become either logs or trace data One source of confusion is that two similarly named integrations do different jobs: - tracing-opentelemetry connects Rust tracing spans and events to OpenTelemetry traces. - opentelemetry-appender-tracing forwards tracing events to the OpenTelemetry Logs pipeline. Both can be installed as layers on a tracing_subscriber. A log record and a span event are related, but they are not the same telemetry signal; the bridge you choose determines where the event goes. In our service setup, the key pieces look like this: let trace_layer = tracing_opentelemetry::layer().with_tracer(tracer); let log_layer = OpenTelemetryTracingBridge::new(&logger_provider); The trace provider and logger provider each receive exporters. The metrics provider is separate again: it creates instruments and collects measurements on its configured interval. All three can share the same OpenTelemetry Resource, so telemetry is attributed to the same service name and version. A small configuration example The AICC crate wraps provider and subscriber setup behind a builder-style API. With a local OpenTelemetry Collector listening on gRPC port 4317, initialization is just a few lines: use aicc::o11y::prelude::*; use tracing::info; let options = TracingOptions::default() .with_service("orders-api", "1.2.0") .with_otlp_exporter(OtlpExporterOptions::new("http://localhost:4317")); let _guard = init_tracing(&options)?; info!(order_id = 42, "Order accepted"); The exporter options enable all three signals by default. In the implementation, separate builders create the OTLP span, metric, and log exporters, and the resulting providers are attached to the subscriber or registered as global providers where needed. The returned guard matters. Keep it alive for the lifetime of the application: when it is dropped, it shuts down the providers and flushes pending telemetry. If you create a provider manually, make sure your shutdown path does the equivalent. A practical detail: exporters can produce telemetry too The OTLP exporter and its transport stack can emit tracing events of their own. If those events are sent through the log bridge, exporting a log could produce another log, which could trigger another export, and so on. The service code avoids that feedback loop by filtering infrastructure targets out of the OpenTelemetry log layer. It still allows warnings and errors from that infrastructure to appear in the console layer, which is useful when the collector is unavailable. This is a small implementation detail, but it is exactly the sort of thing that turns a “hello, telemetry” demo into a setup you can operate. Metrics are collected when requested OpenTelemetry observable instruments use callbacks that run during collection. They are a good fit for values such as current CPU or memory usage: the application registers how to read the value, and the SDK asks for it when a metrics reader collects data. In this project, the server registers observable system metrics using sysinfo. The callbacks refresh and read the relevant values; there is no separate polling task that wakes up just to update gauges. If you adapt the example for your own service, you can add application counters or histograms alongside those system metrics. One caveat for a demo: the OTLP example initializes all three pipelines and emits spans and log records, but it does not record a custom metric. To demonstrate all three signals end to end, add a metric instrument or run the server path that registers the system metrics. What should you take away? OpenTelemetry Rust 0.33.1 is a meaningful point to revisit your observability setup: 1. Logs and metrics have stable OTLP exporters. That is useful if you want to standardize your output format and backend integration. 2. Tracing is still beta. Treat its API stability separately rather than assuming every signal has the same guarantee. 3. Retries are now on by default for OTLP exporters. That improves resilience to transient failures, but it is still worth monitoring exporter errors and having a clear shutdown path. 4. tracing can feed more than one signal. Use the trace bridge for spans and the appender for OpenTelemetry log records, and filter exporter-generated events to prevent feedback loops. The interesting question is no longer just “Can Rust send telemetry to OpenTelemetry?” It can. The more useful question is: which signals are stable in the versions you deploy, and what does your application do when the collector is slow or unavailable? References - OpenTelemetry Rust 0.33 release notes - opentelemetry-otlp changelog - opentelemetry-otlp 0.33.1 documentation - opentelemetry-appender-tracing documentation
MCP Monitoring: Why Your Agents Fail Silently and How to Catch It Your MCP server is returning 200s. Your uptime dashboard is green. And an agent somewhere just told a user "I couldn't complete that request" for the fortieth time today. That gap is what MCP monitoring is about. When an MCP server breaks, a person rarely sees an error page. An agent reads a failure, retries, picks a different tool, or gives up and summarizes the problem in natural language. None of that shows up in a classic HTTP monitor. This guide covers why MCP needs its own monitoring, which signals are worth capturing, the main tools you can use today, and a walkthrough of one hosted option. Why MCP monitoring is different from API monitoring Classic API monitoring assumes a request, a status code, and a human or a service that notices when things go wrong. MCP breaks most of those assumptions. Failures can be invisible at the transport layer. In MCP, a tool that fails can return a normal JSON-RPC result with isError: true in the body. The HTTP response is still a success. If you only count 5xx responses, you will miss every tool-level error. Agents don't file tickets. A human who hits a broken endpoint tries once and complains. An agent may retry in a loop, burn tokens, and move on. The only witness is the traffic itself. Tool misuse looks like success. An agent can call the wrong tool, or the right tool with poorly chosen arguments, and get a valid response. Usually the cause is a vague description or two tools that look alike. Your server did its job. The user still got a bad answer. Latency compounds. One agent task can chain several tool calls. A tool with a slow p95 doesn't just slow one request. It stretches the whole task, and clients may time out partway through. Schemas drift. Agents read your tools/list output and take input schemas literally. Rename a parameter or remove a tool, and clients that cached the old shape start failing. Nothing on the server side registers it as an error. The handshake can break while the host is up. A server can answer a ping and still fail initialize, return an HTML error page where JSON-RPC should be, or reject credentials after an OAuth change. What signals to capture Whatever tool you pick, aim to capture these: - Handshake health: whether initialize succeeds, how long it takes, and which protocol version and transport were negotiated. - Tool inventory: the current tools/list output, compared against the previous one, so added, removed, or changed tools are visible. - Per-tool error rate: including results with isError: true, not just protocol or HTTP errors. - Latency percentiles per tool: p50 and p95 at minimum, since averages hide the slow tail agents time out on. - Caller identity: which client or agent made the call (Claude, Cursor, a partner's bot), and which key or user it used. - Token or payload size: large, unpaginated responses are expensive for the agent even when they succeed. - Retries and loops: repeated calls to the same tool with similar arguments in a short window. - Trace context: a trace ID that links the agent's LLM call to the tool call to your backend, so you can follow one task end to end. Approaches and tools There is no single right layer. Some teams instrument the server code, some trace the client, some put a gateway in front, and some run synthetic checks from outside. Most production setups combine at least two of these. OpenTelemetry with the MCP semantic conventions OpenTelemetry now has semantic conventions for MCP, currently marked as in development. They define client and server spans named {mcp.method.name} {target}, for example tools/call get-weather, with attributes such as mcp.method.name, mcp.protocol.version, and gen_ai.tool.name. They also define duration metrics like mcp.server.operation.duration. Trace context travels inside the MCP request's params._meta rather than only in HTTP headers, which SEP-414 documents in the MCP spec. That matters because one MCP request can span several HTTP requests, and one HTTP stream can carry several MCP messages. The official Python and TypeScript MCP SDKs emit these spans once you register an OpenTelemetry SDK. Per the Python SDK docs, a tool result with is_error=True sets the span status to error, which covers the silent-failure case above. The TypeScript docs note that spans don't record tool arguments or results by default. From there you can send traces to any OpenTelemetry-compatible backend, such as Grafana Tempo, Jaeger, or a vendor platform. This is the most portable approach and works for self-hosted servers. You build the dashboards and alerts yourself. Datadog Agent Observability Datadog's MCP client guide covers automatic instrumentation for the official Python and JavaScript MCP SDKs, and manual instrumentation for other clients, including Java. For Python, the setup from their docs is: pip install ddtrace export DD_LLMOBS_ENABLED=true export DD_LLMOBS_ML_APP=<YOUR_ML_APP_NAME> export DD_LLMOBS_AGENTLESS_ENABLED=true export DD_API_KEY=<YOUR_API_KEY> ddtrace-run <YOUR_APP_STARTUP_COMMAND> According to Datadog's auto-instrumentation docs, the Python integration also instruments the server side (initialize and tools/call), tagging spans with the client name and version. The manual instrumentation example explicitly marks a span as an error when result.isError is true. Their announcement post describes linking MCP spans to the LLM span that chose the tool, with aggregated p95 latency and error rates per tool and server. This fits best if you already run Datadog and want MCP traces next to the rest of your APM data. Speakeasy Gram tool logs If your servers are hosted on Speakeasy's platform, the Tool Logs page is a raw log of every tool call. Its docs list filters for server, client (as reported at the MCP handshake), status, agent, and user, plus a summary strip with failure rate and blocked rate. MCP Insights rolls this up into call volumes and the tools with the most errors. Logs can also be exported to a collector, and there's an API for searching them. MCP Inspector The MCP Inspector is the official interactive tool for connecting to a server, listing its tools, and calling them by hand. It isn't a monitoring system, since nothing runs on a schedule and nothing alerts you. It's still the quickest way to reproduce a failure once your monitoring has flagged one. Elva MCP Monitor and MCP logs Elva, which builds hosted MCP servers from APIs, takes two angles. Elva-hosted servers come with call logs and insights. A newer feature, MCP Monitor, runs scheduled synthetic checks against any remote MCP server, including ones not hosted on Elva. The walkthrough below follows Elva's documentation. Walkthrough: setting up an MCP monitor in Elva Everything in this section comes from the MCP Monitor docs. What each check does Every check runs up to three steps: 1. Handshake: runs the MCP initialize handshake and records latency, transport, and protocol version. 2. Tool inventory: lists the tools and compares names and input schemas with the previous check. This is the schema drift signal from earlier. 3. Read-only tool call (optional): calls one read-only tool with arguments you choose, as a synthetic test. The result is one of four statuses: - Up: the handshake succeeded and every enabled check passed. - Degraded: the server answered, but tools/list failed, the tool call failed, or latency went over your threshold. - Down: no valid MCP response, for example DNS or TLS failure, timeout, 5xx, an HTML page instead of JSON-RPC, or rejected credentials. - Auth required: the server needs a credential or an OAuth reconnect before it can be checked. A 429 is reported as rate limited, not as down. Step 1: Add the server Open MCP Monitor and enter the server URL, or pick one of your Elva-hosted servers. Step 2: Authenticate Choose None, Bearer token, API key header, Custom header, or OAuth. Per the docs, saved headers are encrypted at rest and not shown again. Step 3: Choose what to check Set the interval (every 1, 5, 15, 30, or 60 minutes), an optional latency threshold in milliseconds, and optionally one read-only tool to call. Free workspaces can check every 5 minutes or slower. Step 4: Set alerts Choose how many consecutive failed checks open an incident (2 by default), whether degraded checks count, and whether to notify when the tool inventory changes. Alerts can go to a Slack incoming webhook, up to five email recipients, or a signed webhook. Each monitor has Checks, Incidents, Tools, Alerts, and Settings tabs, and Send test on the Alerts tab tries every configured channel. An incident opens when the failure threshold is reached and resolves on the next healthy check. Handling webhook alerts Webhook events are mcp_monitor.incident.opened, mcp_monitor.incident.resolved, mcp_monitor.tools.changed, and mcp_monitor.test. Here's the example incident payload from the docs: { "event": "mcp_monitor.incident.opened", "occurredAt": "2026-11-02T10:00:00.000Z", "monitor": { "name": "Orders MCP", "url": "https://mcp.example.com/mcp", "status": "down", "slug": "orders-mcp", "statusPageUrl": "https://app.getelva.ai/status/orders-mcp" }, "incident": { "startedAt": "2026-11-02T09:58:00.000Z", "cause": { "status": "down", "failureCode": "timeout", "message": "Handshake timed out" } } } Tool-change events include a toolsDiff with added, removed, and changed tools. Deliveries are signed with the same scheme as Elva's MCP webhooks, using the X-Elva-Signature and X-Elva-Timestamp headers, so verify them before acting on an event. Status pages and one-off checks You can publish a public status page at app.getelva.ai/status/<slug> and embed an SVG badge in a README. There's also a one-off check you can run without signing in, which produces a shareable report on the handshake, protocol version, transport, tools, and OAuth discovery. On plan limits, the docs say Free workspaces can save 10 monitors with a 5-minute minimum interval. Looking at real traffic Synthetic checks tell you whether a server works. They don't tell you what agents actually do with it. For Elva-hosted servers, the MCP insights and logs page covers the traffic side: total requests, response-time percentiles, error rate, and token estimates over a date range, plus per-agent cards. Calling clients such as Claude, Cursor, Windsurf, and MCP Inspector are detected automatically from their requests. You can also follow logs from the terminal with elva-cli 1.2.0 or later: elva mcp logs orders-mcp --follow I covered Elva briefly in an earlier post, Postman Alternatives in 2026, from the API testing side. This post looks at the monitoring side. How to choose - You run your own server and want portability: use the OpenTelemetry MCP conventions through the official SDKs, then send the data to the backend you already use. - You're already on Datadog: its MCP integration puts tool calls inside the same traces as the LLM calls that triggered them. - Your servers live on a hosted platform: start with that platform's logs (Gram's Tool Logs, Elva's MCP logs), then add external checks. - You need to know when a server breaks for agents, wherever it's hosted: add a synthetic monitor that runs the handshake and checks the tool list on a schedule. Elva's MCP Monitor is one option. A scheduled script using an official MCP SDK client is another. - You're debugging one failure right now: open MCP Inspector. MCP monitoring checklist - [ ] Count tool results with isError: true as errors, not just HTTP and JSON-RPC errors. - [ ] Track p50 and p95 latency per tool, not per server. - [ ] Record which client or agent made each call. - [ ] Snapshot tools/list and alert when names or input schemas change. - [ ] Run a scheduled initialize handshake from outside your network. - [ ] Include at least one read-only synthetic tool call. - [ ] Propagate trace context through params._meta so agent, tool, and backend spans connect. - [ ] Watch response size and token usage for unpaginated tools. - [ ] Alert on repeated retries of the same tool from the same caller. - [ ] Treat auth and OAuth failures as their own status, separate from downtime. - [ ] Keep MCP Inspector handy for reproducing what your monitors find.
The implementation follows OpenTelemetry's semantic conventions for MCP, defining how MCP operations are traced and measured github.com/open-telemetry/semantic-conventions/bl…
AIエージェントの実行証跡をどう設計するか ― 監査・説明責任・障害調査 AIエージェントが業務システムを操作したとき、後からその処理をどこまで追跡できる必要があるだろうか。 通常のWebアプリケーションでも、アクセスログや監査ログを使って操作履歴を確認できる。 では、AIエージェントでも同じログを収集すれば十分なのだろうか。 AIエージェントでは、ユーザーの依頼を受けてLLMがToolを選択し、取得した情報をもとに次の操作を判断する。 そのため、業務APIの呼び出し結果だけでは、実行に至った経緯を把握できない場合がある。 今回は社内の経費精算システムを例に、監査・説明責任・障害調査・セキュリティという複数の目的を満たす実行証跡をどう設計するか考える。 1. 実行証跡は何のために必要なのか まず、ログを収集する目的を整理する。 目的 確認したいこと 監査 誰が何を実行し、権限や手続きに問題がなかったか 説明責任 AIがどの情報をもとに何を行ったか説明できるか 障害調査 どこで失敗し、なぜ期待した結果にならなかったか セキュリティ 不正な操作や異常な動作が発生していないか 目的は異なるが、必要な情報には重複が多い。 例えば、Toolの実行履歴は、障害調査だけでなく監査やセキュリティ調査にも利用できる。 そこで、目的ごとに別々のログ出力処理を実装するのではなく、共通の実行証跡を収集し、複数の目的で活用する構成を考える。 また、証跡を共通的に収集することと、それぞれの目的の要件を満たすことは別だ。 例えば監査では、必要な記録を欠落させないことや、保存期間、改ざん防止などが求められる場合がある。 説明責任についても、ログだけで完結するわけではなく、責任者の明確化や問題発生時の対応も必要になる。 収集はできるだけ共通化し、保存・管理は目的に応じて設計する。 2. 通常のWebアプリと何が違うのか 経費精算システムで、先週の大阪出張の経費を提出する場面を考える。 通常のWebアプリでは、次のように操作する。 ユーザーが申請一覧画面を開く ↓ アプリが申請一覧APIを呼ぶ ↓ ユーザーが対象申請を選ぶ ↓ アプリが申請詳細APIを呼ぶ ↓ ユーザーが内容を確認して提出 ↓ アプリが提出APIを呼ぶ アクセスログで誰がいつAPIを呼び出したかを確認し、業務DBや変更履歴から申請内容を調査できる。 これらが適切に記録・関連付けられ、監査要件を満たしているなら、専用の監査ログを別途実装する必要はない。 一方、AIエージェントには、ユーザーが次のように依頼する。 先週の大阪出張の経費を提出しておいて。 この場合は、次のように処理が進む。 ユーザーが経費精算を依頼 ↓ LLMが申請一覧取得Toolを選択・実行 ↓ LLMが対象申請を選ぶ ↓ LLMが申請詳細取得Toolを選択・実行 ↓ LLMが提出Toolを選択 ↓ ユーザーが内容を確認して承認 ↓ 提出Toolから提出APIを呼ぶ どちらも申請一覧の取得、申請内容の取得、提出という業務処理は同じだ。 違うのは、通常のWebアプリではユーザーが対象や操作を選び、アプリが決められたAPIを呼ぶのに対し、AIエージェントではLLMが取得した情報をもとに次のToolや対象を選ぶことだ。 例えば、エージェントが間違った申請を提出してしまったとする。 業務サービスの記録からは、どの申請が提出されたか確認できる。しかし、それだけでは次の疑問に答えられない。 ユーザーは何を依頼したのか エージェントはどの申請情報を参照したのか どのToolと引数を選択したのか ユーザーはどの内容を承認したのか これらを調査するには、業務APIの呼び出し以前の実行経緯が必要になる。 ただし、LLMの内部的な推論過程を完全に記録・再現できるわけではない。後からLLMに判断理由を説明させても、実際の判断過程を正確に表している保証はない。 重要なのは、内部的な思考を再現することではなく、参照情報、LLMの出力、Toolの選択、承認、実行結果など、実際に観測できた事実を追跡することだと考える。 3. 何を記録する必要があるのか 経費精算エージェントを例に、記録対象を整理する。 分類 記録情報の例 共通的に収集 実行日時、実行主体、実行ID、Tool名、呼び出し結果 イベント発生時に記録 認可失敗、承認・否認(承認者・対象・日時・結果など)、重要な業務状態変更 リスク・監査要件に応じて追加 認可成功の詳細、適用したルール、対象データ、モデルやAgent定義のバージョン 個別に判断 依頼全文、LLM入出力、Tool引数・結果の全文、参照文書の内容 この分類は設計の出発点であり、すべてのシステムで必須となる項目を示したものではない。 参照した情報をどう残すか 例えば、エージェントが誤った申請を選択した場合。 原因を調査するには、LLMがどのような申請情報を受け取っていたか確認したい。 しかし、取得した申請一覧をすべてログに保存すると、情報量が増え、個人情報の管理も難しくなる。 そこで、参照データのIDやバージョンを記録し、必要な場合に実行時点のデータを確認する方法が考えられる。 ただし、データが更新・削除されるなら履歴管理が必要になる。また、取得したデータを加工してLLMに渡す場合は、その内容も追跡できるか検討する。 モデルやプロンプトの変更が誤処理に影響する可能性もある。 重要な処理では、モデル識別子、プロンプトやAgent定義のバージョン、Toolの実装バージョンなども記録候補になる。 実行されなかった操作も記録する もう一つ重要なのが、Tool実行前に拒否された操作だ。 例えば、エージェントが権限外の申請を提出しようとしたが、認可処理で拒否された場合。 業務APIが呼び出されていなければ、そのアクセスログには何も残らない。 しかし、セキュリティ調査では、エージェントが何を実行しようとし、どの制御によって拒否されたかが重要になる。 認可失敗は原則として記録する。また、承認・否認が行われた場合は、操作の重要度だけで判断せず、誰が何に対してどのような判断をしたかを残す。認可成功の詳細をどこまで記録するかは、業務リスクや監査要件に応じて決める。 OWASPのAI Agent Security Cheat Sheetでも、高リスク操作の認可結果、承認ID、適用したポリシー、実行結果などの記録が推奨されている。 4. 実行証跡をどう収集するか 必要な情報を整理できたら、次はどこで収集するかを考える。 すべてを個別実装する必要はない。既存のLoggerやOpenTelemetryを活用し、不足する情報だけを追加する。 共通Loggerで収集する 日時、ユーザーID、リクエストIDなどは、共通のログフォーマットで出力できる。 例えばJavaなら、SLF4JのMDCなどを使い、処理中のログにユーザーIDやリクエストIDを自動付与できる。 AIエージェントでは、それに加えて一連の実行を関連付けるIDを用意したい。 例えば、次のような関係になる。 経費精算の依頼(実行ID:run-001) │ ├─ 申請一覧を取得 │ ├─ 申請内容を取得 │ ├─ 提出操作の承認待ち │ └─ 承認後に提出APIを実行 各Toolの呼び出しにも個別の識別子を付け、一連の実行IDと関連付ける。 実行IDは処理コンテキストを通じて引き継ぎ、各コンポーネントのログやトレースに付与する。追跡のために、既存の業務DBの各テーブルへ実行IDを追加する必要はない。 承認待ちなどで処理が中断され、翌日再開される場合もある。 HTTPリクエストや分散トレースのIDは再開前後で変わる場合があるため、一連の業務依頼を追跡できるIDを保持するとよい。 なお、IDの名称や管理方法は利用するフレームワークによって異なる。既存の識別子を活用できるなら、新たに作る必要はない。 OpenTelemetryで収集する LLMやToolの実行については、OpenTelemetryのGenAI Semantic Conventionsを活用できる。 これは、生成AIの処理をトレースするために、記録する情報の名前や形式を共通化する仕様だ。 例えば、次のような情報を表現できる。 情報 OpenTelemetryの属性例 実行した処理 gen_ai.operation.name Tool名 gen_ai.tool.name Tool呼び出しID gen_ai.tool.call.id Toolの引数 gen_ai.tool.call.arguments Toolの結果 gen_ai.tool.call.result ここで注意したいのは、OpenTelemetryを導入すれば、すべての情報が自動的に記録されるわけではないことだ。 実際に取得できる内容は、利用するフレームワークや計測ライブラリの対応状況によって異なる。 また、Toolの引数やLLMの入出力には機密情報が含まれる可能性があるため、必要な場合に限定して取得する。 OpenTelemetryは共通の記録形式を提供するが、何を取得し、どこまで残すかはシステム側で設計する必要がある。 なお、GenAI関連の仕様は更新が続いているため、実装時には利用するバージョンと対応状況を確認する。 参考:OpenTelemetry GenAI Semantic Conventions どのコンポーネントで記録するか 既存の経費精算システムにAIエージェントを追加する場合、例えば次の構成になる。 ユーザー ↓ Agent基盤 │ Tool選択・承認 ↓ MCP Server │ Tool受付・実行 ↓ 業務API │ 業務処理 ↓ 業務DB 記録対象は次のように分けられる。 コンポーネント 主な記録対象 Agent基盤 LLM呼び出し、Tool選択、承認・拒否 MCP Server Toolの受付・実行結果 業務サービス 業務処理結果、対象データの変更 例えば、Toolの呼び出しが正常終了しても、非同期で実行される後続の業務処理が失敗することはある。 そのため、最終的な業務処理結果は業務サービス側で確認する。 既存のアクセスログやDBの変更履歴を置き換えるのではなく、Agent側の実行証跡と関連付けて活用する。 これにより、誤った経費申請が提出された場合も、次のように調査できる。 実行IDからユーザーの依頼を特定する Agent側の記録から参照情報とToolの選択を確認する 承認履歴からユーザーが何を承認したか確認する 業務サービスの記録から実際の提出結果を確認する これらを関連付けることで、最初の依頼から実際の業務操作までを追跡できる。 5. 実行証跡をどう保存・管理するか ここまで収集方法を整理したが、取得した情報をすべて同じ方法で保存すればよいわけではない。 例えば、分散トレースでは性能調査のために一部の記録を間引いたり、短期間で削除したりする場合がある。 一方、監査に必要な操作履歴を同じように間引くと、必要な証跡が残らない可能性がある。 そこで、記録項目は共通化しつつ、保存方法は目的に応じて分ける構成を考える。 これは論理的な構成であり、必ず別々のログ基盤を用意するという意味ではない。 同じ基盤を使いながら、記録の種類によって保存期間や保存方法を変えることもできる。 ただし、収集や転送の途中で失われた記録は、保存先を分けても復元できない。 そのため、監査に必要な重要イベントでは、収集から保存までの一連の処理で欠落を防ぐ設計が必要になる。 監査に必要な管理要件 監査に利用する記録では、主に次の点を検討する。 観点 設計内容 記録の欠落防止 必要な操作履歴を確実に残す 保存期間 業務・法令・契約要件に従って決める 改ざん対策 不正な変更・削除を防止・検知する アクセス制御 閲覧・管理できる人を制限する 機密保護 個人情報や認証情報を保護する 追跡可能性 関連するログやDB情報を対応付ける これらは従来のWebアプリでも必要な設計であり、AIエージェント固有の仕組みを作る必要はない。 ただし、重要な操作では、ログの保存に失敗した場合の扱いも決める必要がある。 例えば、経費申請の正式提出で、提出自体は成功したのに必要な監査記録が残らなかったとする。 この状態を許容できない業務なら、業務処理と監査記録の整合性を確保する必要がある。 業務DBと同じトランザクションで記録する方法や、記録を確実に転送・再送する仕組みなどが考えられる。 重要なのは、ログ出力処理を追加しただけで、監査に必要な記録が必ず残るとは限らないことだ。 参考:OWASP Logging Cheat Sheet 6. まとめ 今回は、AIエージェントの実行証跡をどう設計するか考えた。 最初の疑問は、 「AIエージェントでも、従来のWebアプリと同じログを収集すれば十分なのか?」 だった。 私の答えは、「従来のログ設計を基本にできるが、LLMによるTool選択や情報参照、承認・拒否など、業務API実行前の経緯も追跡する必要がある」だ。 今回の設計では、次のように整理した。 監査・説明責任・障害調査・セキュリティに必要な情報を、共通の実行証跡として収集する LoggerやOpenTelemetryを活用し、個別実装を最小限にする 業務固有の情報は既存システムで管理し、実行IDなどで関連付ける 監査に必要な保存期間、改ざん対策、記録の欠落防止などは別途満たす LLMの入出力やTool引数など、機密情報を含む内容の記録範囲は慎重に決める AIエージェントの実行証跡設計では、ログの種類を増やすのではなく、何を追跡できる必要があるかを整理し、既存の仕組みと組み合わせて証跡を確保する。 これが今回の設計の結論だ。
Claude CodeのOpenTelemetryをBigQueryに流してAIコーディングのコストと失敗を可視化する はじめに AIコーディングエージェントを日常業務に組み込むと、ほどなくして「で、これ結局いくらかかってるんだ?」という疑問にぶつかります。 月末の請求額は分かる。でも、どのタスクが高かったのか、どのツール呼び出しが失敗を繰り返しているのか、キャッシュはちゃんと効いているのかは分からない。ダッシュボードの合計値だけ見て「まあ、こんなもんか」と流している方も多いのではないでしょうか。 これは要するにオブザーバビリティの問題です。そして幸いなことに、Claude Code は OpenTelemetry をネイティブでサポートしています。メトリクス・イベント・分散トレースを OTLP で吐き出せるので、あとは受け止めて分析するだけです。 この記事では、Claude Code のテレメトリを OpenTelemetry Collector で受け、GCP 経由で BigQuery に着地させて SQL で分析するまでを構築します。普段 GA4×BigQuery でデータ基盤を触っている人間にとっては、慣れた道具でそのまま料理できる構成です。 ! 本記事の設定ファイルは OpenTelemetry Collector v0.115.0(contrib ディストリビューション)で validate と疎通を確認済みです。Claude Code 側の環境変数・メトリクス名・属性名は公式ドキュメントの記載に準拠しています。 自己紹介 普段は EC 事業者向けに GA4×BigQuery のデータ基盤構築と分析を請け負っています。Looker Studio でのダッシュボード化、dbt での 3 層設計、最近は Claude Code を使った SQL 生成・レポート自動化まわりが主戦場です。 つまり「BigQuery にデータを置いて SQL で殴る」のが手癖なので、今回もその型に持ち込みます。 なぜ「AIエージェントの可視化」なのか 従来のオブザーバビリティは、リクエストを受けてレスポンスを返すサーバーが対象でした。AIエージェントはここに厄介な性質を持ち込みます。 1回の指示のコストが数十倍ブレる — 同じ「バグを直して」でも、5秒で終わることも、20回ツールを叩いて数ドル溶かすこともある 失敗が可視化されにくい — ツール実行が失敗してもエージェントがリトライして最終的に成功するので、内部の失敗率は表に出てこない コストの内訳が入れ子 — サブエージェント、スキル、MCP サーバー呼び出しが階層的に発生する 「合計いくら」ではなく「どのプロンプトが、どの経路で、いくら使ったか」が見えないと改善のしようがありません。これはまさにトレースとメトリクスが解く問題です。 全体構成 Claude Code (OTLP) │ ├─ metrics ─┐ ├─ logs ────┼─→ OpenTelemetry Collector └─ traces ──┘ │ ├─→ Cloud Monitoring (メトリクス:アラート・監視) ├─→ Cloud Trace (トレース:1プロンプトの内訳) └─→ Cloud Logging (イベント) │ └─→ ログシンク → BigQuery → SQL 分析 ポイントは イベント(ログ)を BigQuery に落とすことです。Claude Code の claude_code.api_request イベントにはトークン数とコストが属性として乗っているため、ここさえ BigQuery に入れば、あとは使い慣れた SQL で好きなだけ切り刻めます。 メトリクスは Cloud Monitoring に置いてアラート用に使い、BigQuery 側は分析用と役割分担させるのがすっきりします。 Step 1:Claude Code のテレメトリを有効化する 環境変数だけで有効になります。 export CLAUDE_CODE_ENABLE_TELEMETRY=1 エクスポート先 export OTEL_METRICS_EXPORTER=otlp export OTEL_LOGS_EXPORTER=otlp export OTEL_EXPORTER_OTLP_PROTOCOL=grpc export OTEL_EXPORTER_OTLP_ENDPOINT=localhost ツールの詳細(Bash のコマンド内容や失敗メッセージ)まで記録する export OTEL_LOG_TOOL_DETAILS=1 検証中は出力間隔を短くすると即座に確認できる export OTEL_METRIC_EXPORT_INTERVAL=10000 export OTEL_LOGS_EXPORT_INTERVAL=2000 取得できる主なメトリクスは以下です。 メトリクス 内容 claude_code.cost.usage セッションのコスト(USD) claude_code.token.usage 消費トークン数 claude_code.lines_of_code.count 変更行数 claude_code.commit.count 作成したコミット数 claude_code.pull_request.count 作成した PR 数 claude_code.session.count セッション数 claude_code.active_time.total アクティブ時間(秒) そして今回の主役、イベント側です。 イベント 内容 claude_code.api_request API リクエスト1回ごとのコスト・トークン・レイテンシ claude_code.tool_result ツール実行の成否・所要時間・エラー種別 claude_code.api_error API 呼び出しの失敗 claude_code.tool_decision ツール実行許可の判断 claude_code.user_prompt ユーザーのプロンプト送信 ! OTEL_LOG_TOOL_DETAILS=1 を有効にすると、Bash で実行したコマンド文字列やツールの引数まで記録されます。便利な反面、機密情報が混ざる可能性があります。共有のバックエンドに送る場合は、この設定を入れるかどうかを必ず検討してください。また既定でも user.email などが送信対象になり得ます。 Step 2:OpenTelemetry Collector を立てる まずローカルで「ちゃんと届いているか」を目視できる構成から始めるのが確実です。いきなり GCP に繋ぐと、届かなかったときに Claude Code 側の問題なのか Collector なのか GCP 権限なのか切り分けられなくなります。 local-test.yaml receivers: otlp: protocols: grpc: endpoint: 127.0.0.1:4317 http: endpoint: 127.0.0.1:4318 processors: batch: timeout: 1s exporters: debug: verbosity: detailed file: path: ./out.jsonl service: pipelines: metrics: receivers: [otlp] processors: [batch] exporters: [debug, file] logs: receivers: [otlp] processors: [batch] exporters: [debug, file] traces: receivers: [otlp] processors: [batch] exporters: [debug, file] 起動します。 contrib 版が必要(googlecloud exporter は contrib にのみ含まれる) curl -sSLO github.com/open-telemetry/opentelemetry-collector… tar xzf otelcol-contrib_0.115.1_linux_amd64.tar.gz 設定ファイルの妥当性を先に確認できる ./otelcol-contrib validate --config local-test.yaml ./otelcol-contrib --config local-test.yaml この状態で別ターミナルから Claude Code を起動して何か作業させると、out.jsonl にテレメトリが流れ込みます。ここで claude_code.cost.usage や claude_code.tool_result が見えれば、Claude Code 側の設定は正しいと確定できます。 Step 3:GCP に送る 疎通が取れたら送り先を差し替えます。googlecloud exporter はトレース・メトリクス・ログの 3 シグナルすべてに対応しており、それぞれ Cloud Trace / Cloud Monitoring / Cloud Logging に着地します。 gcp.yaml receivers: otlp: protocols: grpc: endpoint: 127.0.0.1:4317 processors: memory_limiter: check_interval: 1s limit_percentage: 65 spike_limit_percentage: 20 resource: attributes: - key: service.name value: claude-code action: upsert batch: timeout: 10s exporters: googlecloud: project: your-project log: default_log_name: claude-code-otel service: pipelines: metrics: receivers: [otlp] processors: [memory_limiter, resource, batch] exporters: [googlecloud] logs: receivers: [otlp] processors: [memory_limiter, resource, batch] exporters: [googlecloud] traces: receivers: [otlp] processors: [memory_limiter, resource, batch] exporters: [googlecloud] 認証はサービスアカウント経由が確実です。 gcloud iam service-accounts create otel-collector --project=your-project for role in roles/monitoring.metricWriter roles/logging.logWriter roles/cloudtrace.agent; do gcloud projects add-iam-policy-binding your-project \ --member="serviceAccount:otel-collector@your-project.iam.gserviceaccount.com" \ --role="$role" done gcloud iam service-accounts keys create ./otel-sa.json \ --iam-account=otel-collector@your-project.iam.gserviceaccount.com export GOOGLE_APPLICATION_CREDENTIALS=./otel-sa.json ./otelcol-contrib --config gcp.yaml memory_limiter を入れておくのは実運用上わりと大事で、これが無いとバックエンド側が詰まったときに Collector がメモリを食い潰します。 Step 4:Cloud Logging から BigQuery へシンクする ここが BigQuery に持ち込む肝です。ログシンクを作ると、以後のログエントリが自動的に BigQuery のテーブルへ流れ込みます。 受け皿のデータセット bq --location=asia-northeast1 mk --dataset your-project:claude_code_otel シンク作成 gcloud logging sinks create claude-code-to-bq \ bigquery.googleapis.com/projects/your-project/datasets/claude_code_otel \ --log-filter='logName:"claude-code-otel"' \ --project=your-project 作成時に出力されるサービスアカウントに、データセットへの書き込み権限を付与します。 出力された writerIdentity を控えて付与する gcloud projects add-iam-policy-binding your-project \ --member="serviceAccount:<出力されたwriterIdentity>" \ --role="roles/bigquery.dataEditor" ! シンクは作成後に発生したログにのみ適用されます。過去分は遡って入りません。先にシンクを作ってから Claude Code を回し始めるのが正解です。 Step 5:BigQuery で分析する まずテーブル構造を確認する ログシンク経由のテーブルは、ログの中身に応じてスキーマが自動生成されます。属性が jsonPayload の下に入るのか labels に入るのかは、Collector のバージョンやログの形で変わります。最初に必ず実物を1行見てください。 SELECT * FROM your-project.claude_code_otel.claude_code_otel_* LIMIT 1; -- カラム名を一覧で確認する SELECT column_name, data_type FROM your-project.claude_code_otel.INFORMATION_SCHEMA.COLUMNS WHERE table_name LIKE 'claude_code_otel_%' ORDER BY ordinal_position; 以下の SQL は属性が jsonPayload.attributes 配下に入る前提で書いています。実際の構造に合わせてパスを読み替えてください。 ① 日別・モデル別のコスト推移 SELECT DATE(timestamp, 'Asia/Tokyo') AS dt, JSON_VALUE(jsonPayload.attributes, '$.model') AS model, COUNT(*) AS requests, ROUND(SUM(CAST(JSON_VALUE(jsonPayload.attributes, '$.cost_usd') AS FLOAT64)), 4) AS cost_usd, SUM(CAST(JSON_VALUE(jsonPayload.attributes, '$.input_tokens') AS INT64)) AS input_tokens, SUM(CAST(JSON_VALUE(jsonPayload.attributes, '$.output_tokens') AS INT64)) AS output_tokens FROM your-project.claude_code_otel.claude_code_otel_* WHERE JSON_VALUE(jsonPayload.attributes, '$."event.name"') = 'api_request' AND DATE(timestamp) >= DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 30 DAY) GROUP BY dt, model ORDER BY dt DESC, cost_usd DESC; ② キャッシュがどれだけ効いているか プロンプトキャッシュはコストに直結します。cache_read_tokens の比率が低いなら、セッションの回し方に改善余地があります。 SELECT DATE(timestamp, 'Asia/Tokyo') AS dt, SUM(CAST(JSON_VALUE(jsonPayload.attributes, '$.input_tokens') AS INT64)) AS input_tokens, SUM(CAST(JSON_VALUE(jsonPayload.attributes, '$.cache_read_tokens') AS INT64)) AS cache_read, SUM(CAST(JSON_VALUE(jsonPayload.attributes, '$.cache_creation_tokens') AS INT64)) AS cache_creation, ROUND( SAFE_DIVIDE( SUM(CAST(JSON_VALUE(jsonPayload.attributes, '$.cache_read_tokens') AS INT64)), SUM(CAST(JSON_VALUE(jsonPayload.attributes, '$.input_tokens') AS INT64)) ) * 100, 1 ) AS cache_hit_pct FROM your-project.claude_code_otel.claude_code_otel_* WHERE JSON_VALUE(jsonPayload.attributes, '$."event.name"') = 'api_request' GROUP BY dt ORDER BY dt DESC; ③ どのツールが失敗しているか これが個人的に一番効きました。エージェントはツールが失敗しても勝手にリトライして最終的に成功させてしまうので、失敗は表に出てきません。しかし失敗はそのままトークンの浪費です。 SELECT JSON_VALUE(jsonPayload.attributes, '$.tool_name') AS tool_name, JSON_VALUE(jsonPayload.attributes, '$.error_type') AS error_type, COUNT(*) AS n, ROUND(AVG(CAST(JSON_VALUE(jsonPayload.attributes, '$.duration_ms') AS INT64))) AS avg_ms FROM your-project.claude_code_otel.claude_code_otel_* WHERE JSON_VALUE(jsonPayload.attributes, '$."event.name"') = 'tool_result' AND JSON_VALUE(jsonPayload.attributes, '$.success') = 'false' GROUP BY tool_name, error_type ORDER BY n DESC LIMIT 20; ツール別の成功率も並べて見ておきます。 SELECT JSON_VALUE(jsonPayload.attributes, '$.tool_name') AS tool_name, COUNT(*) AS calls, COUNTIF(JSON_VALUE(jsonPayload.attributes, '$.success') = 'false') AS failures, ROUND( COUNTIF(JSON_VALUE(jsonPayload.attributes, '$.success') = 'false') / COUNT(*) * 100, 1 ) AS failure_pct FROM your-project.claude_code_otel.claude_code_otel_* WHERE JSON_VALUE(jsonPayload.attributes, '$."event.name"') = 'tool_result' GROUP BY tool_name HAVING calls >= 10 ORDER BY failure_pct DESC; ④ 「高かったプロンプト」を特定する prompt.id は、1つのユーザープロンプトから派生したすべてのイベントに付与される UUID です。これを使うと 「あの指示、結局いくらかかったの?」に答えられます。 WITH per_prompt AS ( SELECT JSON_VALUE(jsonPayload.attributes, '$."prompt.id"') AS prompt_id, MIN(timestamp) AS started_at, SUM(CASE WHEN JSON_VALUE(jsonPayload.attributes, '$."event.name"') = 'api_request' THEN CAST(JSON_VALUE(jsonPayload.attributes, '$.cost_usd') AS FLOAT64) END) AS cost_usd, COUNTIF(JSON_VALUE(jsonPayload.attributes, '$."event.name"') = 'api_request') AS api_calls, COUNTIF(JSON_VALUE(jsonPayload.attributes, '$."event.name"') = 'tool_result') AS tool_calls, COUNTIF(JSON_VALUE(jsonPayload.attributes, '$."event.name"') = 'tool_result' AND JSON_VALUE(jsonPayload.attributes, '$.success') = 'false') AS tool_failures FROM your-project.claude_code_otel.claude_code_otel_* WHERE JSON_VALUE(jsonPayload.attributes, '$."prompt.id"') IS NOT NULL GROUP BY prompt_id ) SELECT * FROM per_prompt ORDER BY cost_usd DESC LIMIT 20; コストの高い順に並べて tool_failures が多いものを見ると、「エージェントが同じところで空回りしていた」プロンプトが浮かび上がります。ここがプロンプト改善や CLAUDE.md 整備の投資先になります。 ⑤ サブエージェント・スキル・MCP の内訳 api_request には agent.name / skill.name / mcp_server.name / query_source が乗るので、コストを構成要素に分解できます。 SELECT COALESCE(JSON_VALUE(jsonPayload.attributes, '$."agent.name"'), '(main)') AS agent, JSON_VALUE(jsonPayload.attributes, '$.query_source') AS query_source, COUNT(*) AS requests, ROUND(SUM(CAST(JSON_VALUE(jsonPayload.attributes, '$.cost_usd') AS FLOAT64)), 4) AS cost_usd FROM your-project.claude_code_otel.claude_code_otel_* WHERE JSON_VALUE(jsonPayload.attributes, '$."event.name"') = 'api_request' GROUP BY agent, query_source ORDER BY cost_usd DESC; サブエージェントは並列で走らせると便利ですが、その分コストも積み上がります。「便利だから」で多用していたものが実際いくらだったのかが、ここで数字になります。 分散トレース(beta)で1プロンプトの中身を見る SQL で「どのプロンプトが高いか」を特定したら、次はその中身をトレースで追います。 export CLAUDE_CODE_ENABLE_TELEMETRY=1 export CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1 export OTEL_TRACES_EXPORTER=otlp これでプロンプトから API 呼び出し・ツール実行までが span として繋がります。 claude_code.interaction ← ユーザープロンプト1回 ├── claude_code.llm_request ← モデル呼び出し ├── claude_code.tool ← ツール実行 ├── claude_code.llm_request └── claude_code.tool Cloud Trace のウォーターフォールで見ると、待ち時間がモデル推論なのかツール実行なのかが一目で分かります。「体感が遅い」の正体がテストの実行時間だった、というようなことが可視化されます。 ! beta 機能のため、仕様が変わる可能性があります。恒久的なダッシュボードを組むより、まずは調査用途で使うのが無難です。 ハマりどころ 1. BigQuery にデータが来ない シンクは作成後のログにしか適用されません。また --log-filter が default_log_name と一致していないと何も流れません。まず Cloud Logging のコンソールで該当ログが見えているかを確認し、そこから下流を辿るのが早いです。 2. Collector は contrib 版が必要 googlecloud exporter はコアの Collector には入っていません。otelcol ではなく otelcol-contrib を使います。設定は validate サブコマンドで起動前に検査できます。 3. スキーマが想定と違う ログシンクのテーブルは自動生成なので、属性の入り方が環境によって変わります。SQL を書く前に INFORMATION_SCHEMA.COLUMNS を見るのが確実です。日付シャードテーブルになるため、ワイルドカード _* での参照と _TABLE_SUFFIX での絞り込みを忘れるとスキャン量が膨らみます。 4. テレメトリ自体のコスト Cloud Logging も BigQuery も従量課金です。エクスポート間隔を短くしたまま放置しないこと、シンクのフィルタを絞ることを意識してください。 実際に運用してみて 【要差し替え:実測値】 このセクションには、実際に自分の環境で1〜2週間回して得られた数字を入れてください。具体的には以下があると説得力が出ます。 期間中の総コストと、その内訳(モデル別 / サブエージェント別) キャッシュヒット率の実測値 最も失敗率が高かったツールと、そのエラー種別 最もコストが高かったプロンプトの正体と、そこから何を改善したか 改善前後でのコスト・失敗率の変化 【ここまで要差し替え】 まとめ Claude Code は OpenTelemetry をネイティブサポートしており、環境変数だけでメトリクス・イベント・トレースを出せる googlecloud exporter で GCP に送り、Cloud Logging のシンク経由で BigQuery に着地させれば、使い慣れた SQL で分析できる prompt.id による相関が強力で、「1つの指示にいくらかかったか」を単位にできる ツールの失敗率は普段見えないが、そのままトークンの浪費になっている AIエージェントは「便利だから使う」フェーズから「コストと品質を管理して使う」フェーズに移りつつあります。計測できないものは改善できないので、まずは可視化から始めるのが結局のところ近道でした。 データ基盤をやっている人間にとっては、送り先が BigQuery になった時点でいつもの仕事です。既存の SQL とダッシュボードの資産をそのまま流用できるのが、この構成の一番おいしいところだと思います。 ! GA4・BigQuery・Looker Studio・AI自動化の構築や設定代行を承っています(中小EC・個人事業主向け/スポット相談1万円〜)。「自社の場合はどうすれば?」のご相談も歓迎です。 ココナラでの販売実績は156件、評価は4.9をいただいています。 ウェブの便利屋(ろじかる) ココナラからのご依頼はこちら → Claudeの活用法を一緒に設計します
After 9 months it merged T_T at this point it’s not just a PR. Anyway it’s in opentelemetry’s go-build-tools, the thing every otel go project builds on :) opentelemetry-go-build-tools#1552 t.co
GitHub ActionsとDockerのビルドを1本のトレースにしてGrafana Cloudで見る ! 2026年10月27日に、GitHubホストランナーのUbuntu 24.04で次のバージョンを使って確認した内容です。送信するシグナルはトレースだけです。 Docker Engine 28.0.4 Buildx v0.29.1 BuildKit v0.33.1 OpenTelemetry Collector contrib v0.164.0 GitHubレシーバーのトレース機能は、このバージョンではalphaです。後述する親スパンのID計算もレシーバーの実装に依存します。 はじめに 先日書いたDockerのビルドを遅くしたコミットをGrafana Tempoのトレースで見つけるでは、手元のビルドをトレースにして、遅くなったコミットやDockerfileの手順を調べました。次のステップはCIです。GitHub Actionsの画面で「Dockerのビルドが遅い」とわかっても、そのステップの中で何に時間がかかったかを調べるには、ビルドのログや記録を別に開く必要があります。ワークフロー全体からDockerfileの手順まで、同じトレースの中でたどれるようにしてみました。 TL;DR BuildKitからGrafana CloudのOTLPエンドポイントへトレースを送信できることを確認しました。CIのステップにつなぐには、OpenTelemetry CollectorのGitHubレシーバーがステップに付けるIDをビルドの前に計算し、TRACEPARENTでBuildxへ渡します。CI側のスパンを送るのは、完了した実行の情報をGitHub APIで取得し、Actionsの中で一時的に動かすCollectorです。その結果、ワークフローと2つのジョブが1本のトレースにまとまり、buildジョブのビルドステップの下にBuildKitのスパンがつながりました。 2つの送信経路 今回の構成では、ビルドのトレースとGitHub Actionsのトレースを別々に送ります。両方でトレースIDと親スパンIDを合わせると、Grafana Cloudで1本に表示できます。 ビルド側の送信元は、OpenTelemetryでトレースを送る機能を持つBuildKitです。Dockerfileの各手順のスパンに加えて、クライアントであるBuildxのスパンも、BuildKitへ転送してまとめて送ります。 一方、GitHub Actions側には、BuildKitのようにトレースを送る送信元がありません。ワークフローの実行の記録はWebhookのイベントやREST APIで取得できますが、トレースの形式にはなっていないためです。この記録をスパンに変換するのが、OpenTelemetry CollectorのGitHubレシーバーです。GitHubレシーバーは、GitHubから届くworkflow_runとworkflow_jobのWebhookのイベントを受け取り、ワークフロー、ジョブ、ステップのスパンを作ります。レシーバーはCollectorに組み込んで動かすコンポーネントなので、GitHub Actions側のスパンはCollectorを通し、エクスポーターでGrafana Cloudへ送ります。 今回は、CI側のイベントの取得から送信までをActionsの中で実行する構成を選びました1。ワークフローの完了後に別のワークフローを起動し、そのランナーで一時的にCollectorを動かします。補助スクリプトが完了した実行の情報をGitHub APIで取得し、Webhookと同じ形式のイベントに組み立てて、ランナー内のGitHubレシーバーへ渡す構成です。BuildKitからの送信だけを試す段階では、このCollectorは使いません。 図1: 矢印はデータの移動を表します。左側では計算したTRACEPARENTをBuildxへ渡します。右側は完了後の別ワークフローで、補助スクリプトがGitHub APIの情報をイベント形式へ変換し、OpenTelemetry Collectorへ渡します。Collector内のGitHubレシーバーでCIスパンを生成して送信します。 題材はBusyBox 1.37.0の小さなイメージです。ファイルをコピーしてRUNで別ファイルへ書き出し、ビルドしたイメージを実行して出力も確かめました。実証用のファイル一式を公開しています。 BuildKitからGrafana Cloudへ まず、Actionsの親スパンを付けず、BuildKitからの送信だけを確認しました。Grafana Cloudで取得した接続情報を、リポジトリのActionsシークレットに次の名前で登録します。 シークレット 設定する値 GRAFANA_CLOUD_OTLP_ENDPOINT /otlpまでを含むOTLPのベースURL GRAFANA_CLOUD_OTLP_HEADERS Grafana Cloudの接続画面から取得したAuthorization=...の認証ヘッダー Actionsのステップでは、これらをOTEL_EXPORTER_OTLP_ENDPOINTとOTEL_EXPORTER_OTLP_HEADERSという環境変数に渡しています。scripts/setup-builder.shの内容は次のとおりです。 #!/usr/bin/env bash set -euo pipefail : "${OTEL_EXPORTER_OTLP_ENDPOINT:?Missing OTLP endpoint}" : "${OTEL_EXPORTER_OTLP_HEADERS:?Missing OTLP authentication headers}" docker buildx create --name ci-otel --driver docker-container \ --driver-opt image=moby/buildkit:v0.33.1 \ --driver-opt env.OTEL_TRACES_EXPORTER=otlp \ --driver-opt env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf \ --driver-opt "env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT%/}/v1/traces" \ --driver-opt "env.OTEL_EXPORTER_OTLP_TRACES_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS}" \ --bootstrap >/dev/null docker-containerドライバーではBuildKitが専用のコンテナで動くので、BuildKitのOTLP設定は--driver-opt env.…でそのコンテナの環境変数として渡します。プロトコルはhttp/protobufで、OTLP/HTTPを使います。トレース専用のエンドポイントは、ベースURLの末尾に/v1/tracesを足したURLです。 このbuilderを選んでdocker buildx build --builder ci-otel --load …を実行すると、BuildxとBuildKitの両方のスパンがGrafana Cloudへ届きました。ベースラインの実行から取得したのは、build .をルートにした87スパンのトレースです。イメージのビルドと実行テストも成功しました。 図2: Grafana Cloudで取得したベースラインのトレース。BuildxとBuildKitの87スパンを確認できます2。 Actionsのステップを親にする ここまでのトレースのルートはbuild .です。GitHub Actionsのビルドステップを親にするには、Buildxを実行するステップに環境変数TRACEPARENTを設定します。Buildxは環境変数からトレースコンテキストを読み込む処理でこの値を取り込み、build .を指定された親の子にします。TRACEPARENTに入れるのは、接続先のトレースIDと、親になるステップのスパンIDです。 ただし、ビルドの時点では親になるステップのスパンはまだありません。CI側のスパンは、ワークフローの完了後に作るからです。それでも親のIDを指定できるのは、GitHubレシーバーがトレースIDとスパンIDを乱数ではなく実行IDやジョブIDから決まった規則で計算するためです。ビルドの前に同じ規則で計算すれば、あとで届くステップのスパンと同じIDが得られます。 補助スクリプトscripts/ci-parent.pyは、今回の実行ID、再実行回数、ジョブIDから、レシーバーのID生成の実装と同じ規則でIDを計算します3。計算部分を抜粋すると次のとおりです。 trace_id = hashlib.sha256(f"{run}{attempt}t".encode()).hexdigest()[:32] step_id = hashlib.sha256(f"{job_id}-Build Docker image-s".encode()).hexdigest()[16:32] traceparent = f"00-{trace_id}-{step_id}-01" TRACEPARENTの値は、W3C Trace Contextのtraceparentヘッダーと同じ00-トレースID-親スパンID-01の形式です。スクリプトは、この値をGITHUB_OUTPUTへ書き出します。 ワークフローでは、このスクリプトの出力をビルドステップだけに渡します。build.ymlから該当部分を抜粋すると、次のようになります。 - name: Calculate build step parent id: build_context env: GH_TOKEN: ${{ github.token }} run: python3 scripts/ci-parent.py - name: Build Docker image env: TRACEPARENT: ${{ steps.build_context.outputs.traceparent }} OTEL_RESOURCE_ATTRIBUTES: vcs.ref.head.revision=${{ github.sha }},ci.run.id=${{ github.run_id }},ci.run.attempt=${{ github.run_attempt }},ci.repository=${{ github.repository }},experiment.mode=linked run: docker buildx build --builder ci-otel --load --tag buildkit-otel-spike:ci . ステップのスパンIDはジョブIDとステップ名から決まるので、ステップ名はスクリプトの計算に使ったBuild Docker imageと一致させます。また、ステップ名はジョブ内で一意にしておきます。同じジョブに同名のステップが2つあると、レシーバーが両方に同じスパンIDを付けてしまうからです。 OTEL_RESOURCE_ATTRIBUTESでは、検索に使う実行IDやコミットを付けています。この属性が付くのはBuildxのスパンだけで、BuildKitのスパンには付きません。 CI側のスパンの送信 ビルドステップが終わっても、ワークフロー全体はまだ実行中です。CI側のスパンは、workflow_runのcompletedで起動する送信用のワークフローが作ります。 送信用のワークフローは、完了通知に含まれる実行IDと再実行回数を環境変数で補助スクリプトへ渡します。 OBSERVED_RUN_ID: ${{ github.event.workflow_run.id }} OBSERVED_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} 再実行回数を指定するのは、取得した時点で最新の再実行ではなく、通知された回のジョブを読むためです。補助スクリプトは、その回のジョブとステップの情報をGitHub APIで取得し、Webhookと同じ形式のイベントに組み立ててランナー内のCollectorへPOSTします。変換の処理はscripts/export-workflow.pyで確認できます。 collector.yamlの内容は次のとおりです4。 receivers: github: webhook: endpoint: 0.0.0.0:19418 path: /events health_path: /health service_name: github-actions scrapers: scraper: github_org: ymotongpoo processors: batch: timeout: 1s exporters: otlp_http/cloud: endpoint: ${env:OTEL_EXPORTER_OTLP_ENDPOINT} headers: Authorization: ${env:GRAFANA_AUTHORIZATION} timeout: 10s sending_queue: enabled: false retry_on_failure: enabled: false service: telemetry: logs: encoding: json pipelines: traces: receivers: [github] processors: [batch] exporters: [otlp_http/cloud] エクスポーターのendpointはベースURLのままです。otlp_httpのエクスポーターが/v1/tracesを付けるので、BuildKitにトレース専用のURLを渡したのとは指定方法が違います。認証ヘッダーには、BuildKitと同じシークレットからAuthorization=の後ろの値を取り出して渡します。 このCollectorは送信のたびに起動して停止する実証用の設定で、送信キューと再試行を無効にしています5。 自分の環境で試すには、2つのシークレットを登録し、ビルド用と送信用のワークフローをデフォルトブランチに置きます。そのうえでBuildKit linked traceを手動で実行し、ビルドとExport completed workflow traceの両方の完了を待ちます。APIの呼び出しに使うのはcontents: readとactions: readの権限を付けたGITHUB_TOKENで、GitHub AppやPATは不要です。 1本につながった結果 ビルド側の実行とCI側のスパンの送信は、どちらも成功しました。ワークフロー全体とビルドの内部が1本のトレースにつながっています。 図3: 同じ実行のワークフロー、2つのジョブ、各ステップが1本のトレースになりました。Build Docker imageの下にbuild .が接続しており、ビルド内部は折りたたんでいます。 サービスごとの内訳は次のとおりでした。 service.name スパン数 記録している処理 github-actions 21 ワークフロー、ジョブ、待ち時間、ステップ buildx 33 ビルドクライアントの処理 buildkitd 54 ビルドデーモンとDockerfileの手順 合計 108 同一トレースIDのスパン ビルド用のbuildと、その完了後に別のランナーで実行するverify-sourceの2ジョブが、同じルートにつながっています。CIの待ち時間やビルド内部の細部を省くと、親子関係は次のようになります。 BuildKit linked trace ├── build │ └── Build Docker image │ └── build . │ └── … │ └── [4/4] RUN cat message.txt > result.txt └── verify-source └── Verify source fixture build .の親IDはBuild Docker imageのスパンIDと一致し、取得した108スパンはすべて同じワークフローのルートへつながっていました6。 GrafanaのExploreでTempoのデータソースを選び、実行した時間帯に絞って、次のTraceQLで検索します。ci.run.idは、OTEL_RESOURCE_ATTRIBUTESでBuildxのスパンに付けた属性です。 { resource.ci.run.id = "40437818776" && name = "build ." } 前回の記事では、ルートのスパンだけを選ぶ条件(span:parentID = "")でビルドの所要時間を集計しました。今回のビルドはbuild .にActionsの親があるので、この条件では選べません。手元のビルドとCIのビルドをまとめて選ぶ条件は、{ resource.service.name = "buildx" && name = "build ." }です。ベースラインとCIの両方のトレースが、この条件の検索結果に入ることを確認しました。 CI側のスパンは、送信用のワークフローが完了してから届きます。トレースを開くのは、両方のワークフローが完了してからにしてください7。 今回確認した範囲 BuildKitからGrafana Cloudへの送信と、2ジョブのワークフローへのビルド内部の接続までを確認しました。ただし、IDの生成規則はOpenTelemetryの共通の仕様ではなく、GitHubレシーバー v0.164.0の実装です。Collectorのバージョンやレシーバーのfeature gateを変えるときは、取得したトレースでbuild .が親のステップにつながっているかを確かめ直してください。 CI側の時刻はGitHub APIが秒単位で記録した値です。そのため、親子の接続が正しくても、BuildKitのスパンが親の時間範囲からわずかにはみ出すことがあります。今回の実行では、build .の終了が親のBuild Docker imageの終了を約0.84秒超えていました。 今回は信頼できるリポジトリでの手動実行だけで、matrix、reusable workflow、forkからのPR、実際の再実行、実行間でのキャッシュ再利用は検証していません8。 おわりに GitHub Actionsのワークフローとステップから、Dockerfileのどの手順やレジストリへの通信に時間を使ったかまで、1本のトレースでたどれるようになりました。自分のCIへ取り込むときには、まずBuildKitの送信だけを確認し、必要に応じてCIの親スパンを追加する順で試せます。 脚注 GitHubレシーバーは、常時稼働するCollectorでGitHubからのWebhookを受け取る使い方を想定しています。組織のCIを継続して記録するなら、その構成のほうが向いています。常時稼働のCollectorなら、送信キューと再試行を有効にして、Grafana Cloudへの送信に失敗したスパンを再送することもできます。ただし、GitHubからWebhookを受けるために、インターネットから届くエンドポイントを用意して運用する必要があります。今回はその準備をせずに試すため、Actionsの中で完結させました。 ↩︎ 図の上部に赤く表示されているHEAD HTTP HEADのエラーは、Docker Registryのトークン認証で最初に返る401の認証要求です。今回の実行では、トークン取得後の再試行が200となり、ビルドも成功しています。 ↩︎ ジョブの数値IDは、GitHub APIで今回の実行のジョブの一覧を取得し、名前がGITHUB_JOBと一致するジョブから取り出します。ジョブの表示名を変えたりmatrixを使ったりする場合は、別の方法でジョブを特定する必要があります。 ↩︎ v0.164.0のGitHubレシーバーは、トレースだけを使う場合でも、scrapersを1つ以上設定することを求めます。パイプラインはトレースだけなので、github_orgを使ったメトリクスの収集は動きません。 ↩︎ Collectorはbatchプロセッサーを通して送るので、レシーバーがHTTP 200を返しても、Grafana Cloudへの送信が成功したとは限りません。そこで補助スクリプトは、停止後のCollectorがJSON形式で出力したログを読み、levelがエラーを示していれば失敗として扱います。 ↩︎ 検証時のスクリプトと検証記録を公開しています。取得した108スパンは、スパンIDがすべて一意で、ルートが1つ、親が見つからないスパンは0でした。この検査では、送信したすべてのスパンがGrafana Cloudに届いたかまでは判定できません。 ↩︎ Tempoの最初の検索結果では、ルートのサービス名が<root span not yet received>と表示されました。その後にトレースIDで取得すると、CI側を含む108スパンがそろっていました。 ↩︎ 送信用のワークフローは、元の実行のコミットにある補助スクリプトを実行します。外部のPRが起点になる構成へ広げるときは、GitHubが説明するworkflow_runの権限と注意点を確認してください。 ↩︎
He co-leads the OTel Injector and Packaging SIGs. The Injector switches on auto-instrumentation at process start for Java, Node.js, .NET and Ruby, with no code changes. open-telemetry/opentelemetry-injector
OpenTelemetry の Semantic conventions を眺めていたら よくみている db.statement や db.connection_string が deprecated になっていたことに気づいてしまった。changelog を見る感じだと1.25 や 1.26 で大きく手が入ったぽいな。opentelemetry.io github.com/open-telemetry/semantic-conventions/bl…