Privacy Boundaries for AI Telemetry
Turning off prompt capture is not enough to enforce a complete telemetry recording policy. Exceptions can still include customer text in spans. If an investigation is limited to ten samples, it can end up with forty if four replicas each track their own counters. Queued samples can exceed their intended retention if the destination resets the expiry upon arrival.
The recording-policy tip establishes what to allow. This article focuses on enforcement: inspect the actual SDK output, authorize capture with a narrow scope, reserve quota across replicas, and carry an absolute expiry through every copy of evidence.
The example is a support assistant that reads a private ticket, fetches a refund policy, calls an eligibility tool, and drafts a reply. An investigation requires the policy excerpt and the generated reply, but should not collect the ticket or account-tool payload.
Verify what the SDK actually records
Keep ordinary message capture explicitly disabled. This composition fragment assumes an existing provider client and uses Microsoft.Extensions.AI 10.10.0:
using Microsoft.Extensions.AI;
IChatClient client = new ChatClientBuilder(providerClient)
.UseOpenTelemetry(
sourceName: "SupportReply.AI",
configure: telemetry => telemetry.EnableSensitiveData = false)
.Build();
Setting this option explicitly overrides OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT for this wrapper. This is not a telemetry allowlist. Reviewing the 10.10.0 source shows there are additional paths:
| Recorded value | Behavior with sensitive capture disabled |
| --- | --- |
| Error status description | The error helper passes the exception message to the activity status. |
| Exception log | When a logger is configured and warnings are enabled, the helper passes the exception to ILogger, independently of span export. |
| Conversation identifier | The wrapper records ChatOptions.ConversationId when supplied and the activity requests data. |
| Tool definitions | It serializes tool definitions with optional properties excluded. Tool identities still need review. |
For example, if a provider exception includes a synthetic customer marker, that marker remains in Activity.StatusDescription even if raw message capture is disabled. The conversation identifier can also link a trace to a private application record.
For a strict routine destination, use application-owned instrumentation built from approved values. Do not connect its tracer provider to the wrapper’s source until the source’s output complies with policy. Review the configured logger and its providers separately. Excluding the activity source does not prevent exception logging. Catching exceptions outside the wrapper does not remove a completed span or an exception already passed to a logger.
Treat any allowed sanitization as specific to the exact fields and signal paths it covers. Removing message attributes alone does not address status descriptions, events, resource attributes, log bodies, and other instrumentation. Collector redaction is an extra safeguard, but it cannot reverse data already sent to an exporter or persistent queue.
These are observations specific to this version, not a guarantee for all SDK releases. Repeat inspections and marker checks with every upgrade.
Keep routine evidence useful and bounded
An operator investigating an ineligible refund often starts with operational evidence:
| Investigation question | Approved routine evidence |
| --- | --- |
| Which behavior was deployed? | Build identity, prompt version, configured model alias, policy version |
| Did retrieval return context? | Retrieval outcome, result count, context size |
| Did the eligibility tool complete? | Registered tool name, duration, controlled outcome code |
| What happened to the reply? | Completion outcome, token usage when available, application validation result |
A result count alone does not establish relevance or provide sufficient evidence. To determine if the model saw a conflicting refund clause, you may need separately authorized access to content.
Constrain not only field names but also the values. Use controlled failure codes. Exception messages, document titles, URLs, and log scopes can leak customer details or secrets. Keep record lookups behind authorized application access. Opaque IDs and hashes still allow correlation and should not be used in high-cardinality metric dimensions.
Review OpenTelemetry Baggage separately, since propagation can expose it to downstream services via HTTP headers. Exclude customer content and credentials, treat incoming baggage as untrusted, and configure propagation at external boundaries.
Make diagnostic capture require a versioned grant
Keep ordinary capture disabled during an incident. Use a dedicated capture service to select permitted fields and write only to a restricted evidence store. A global content flag would gather unrelated conversations as well.
An authorized service owner can issue a grant with the following limits. These values are illustrative, not SDK defaults or universal limits:
| Property | Authorized decision |
| --- | --- |
| Purpose and scope | Refund-policy investigation for one tenant, the support-reply operation type, and an explicit set of operation IDs |
| Fixed capture schema | Policy excerpt and generated reply. Exclude the private ticket and account-tool payload |
| Collection window | Thirty minutes. Revoke earlier if necessary |
| Shared quota | Ten reservations across all replicas, with each payload at most 16 KiB after redaction and UTF-8 serialization |
| Destination and readers | Restricted evidence store for assigned investigators |
| Evidence lifetime | Twenty-four hours from collection, independently of the collection window |
An operation ID comes from trusted application context. It is not a model argument or an investigation header used for authorization. If the incident is linked to a conversation, resolve authorized operations on the server and list those IDs. A broad tenant-wide capture requires separate justification.
This application supports a single fixed diagnostic schema: policy excerpt and generated reply. A grant authorizes only this schema and cannot select arbitrary fields. Adding or changing fields requires a separately reviewed schema and capture path with new grants.
Assign each grant a version. Changes to scope, collection deadline, evidence lifetime, or byte limit invalidate old authorizations at reservation time. The capture writer cannot expand a grant or read evidence. Investigator access is a separate role.
Put selection, reservation, and expiry on one path
The following C# code shows the enforcement boundary inside a capture service. The application-specific collaborators described below are not features provided by OpenTelemetry:
public sealed record CaptureGrant(
Guid Id, int Version, string TenantId, string OperationType,
IReadOnlySet<Guid> OperationIds, DateTimeOffset CollectUntilUtc,
TimeSpan EvidenceTtl, int MaxUtf8Bytes);
public async Task TryCaptureAsync(
CaptureGrant grant, TrustedOperation operation, CancellationToken ct)
{
if (grant.TenantId != operation.TenantId
|| grant.OperationType != operation.Type
|| !grant.OperationIds.Contains(operation.Id))
return;
using var budget = CancellationTokenSource.CreateLinkedTokenSource(ct);
budget.CancelAfter(TimeSpan.FromSeconds(2));
var slot = await reservations.TryReserveAsync(grant, operation, budget.Token);
if (slot is null)
return;
// This type exposes only the two fields authorized by this design.
var selected = new DiagnosticFields(operation.PolicyExcerpt, operation.Reply);
byte[]? safePayload = redactor.TrySerializeUtf8(
selected, grant.MaxUtf8Bytes, budget.Token);
if (safePayload is null || safePayload.Length > grant.MaxUtf8Bytes)
return;
var now = clock.GetUtcNow();
var expiresAt = slot.CollectedAtUtc + grant.EvidenceTtl;
if (now >= slot.CollectUntilUtc || now >= expiresAt)
return;
var sample = new EvidenceSample(slot.SampleId, grant.Id, grant.Version, operation.Id,
slot.CollectedAtUtc, expiresAt, safePayload);
await evidence.WriteIfFreshAsync(sample, budget.Token);
}
grant is an immutable, server-authorized snapshot loaded after verifying the investigation purpose and the requesting identity. TrustedOperation is provided by the application. The first guard checks for context mismatches before selecting content.
reservations revalidates the current grant, including its version, scope, revocation status, deadline, and shared quota. It returns a unique sample ID and database timestamp only after committing the reservation. CollectedAtUtc records the time the reservation was admitted, before payload selection and redaction, ensuring that processing or transport delays do not extend retention.
redactor serializes only DiagnosticFields, respects the byte and cancellation budgets, removes required recognized secrets, and returns no payload if it cannot verify removal. This does not guarantee that arbitrary text is non-sensitive, so the destination must be authorized for any remaining content. Do not serialize the entire operation and then try to remove private fields afterward.
evidence accepts the same sample ID idempotently and checks the absolute expiry again when admitting the sample. The fragment omits exception handling. Its calling capture boundary must catch operational exceptions from these collaborators, including capture timeouts, without failing the user request. On failed redaction or an operational failure, skip capture and record only a controlled outcome such as capture_skipped. Do not log the payload or exception text as a fallback. The example’s two-second budget requires all collaborators to honor cancellation and keep their work bounded.
Reserve quota atomically across replicas
A separate read of the remaining quota followed by an unconditional write races. In PostgreSQL, use a short transaction: acquire a lock on the grant row, check the deadline, and then perform a conditional increment:
BEGIN;
SELECT id FROM capture_grants
WHERE id = @grant_id FOR UPDATE;
WITH admission AS MATERIALIZED (
SELECT clock_timestamp() AS collected_at_utc
)
UPDATE capture_grants
SET samples_used = samples_used + 1
FROM admission
WHERE id = @grant_id AND version = @version
AND tenant_id = @tenant_id AND operation_type = @operation_type
AND @operation_id = ANY(operation_ids)
AND revoked_at IS NULL
AND collect_until_utc > admission.collected_at_utc
AND samples_used < max_samples
RETURNING admission.collected_at_utc, collect_until_utc;
COMMIT;
Here, id is the primary key, operation_ids is a UUID array, and timestamps use timestamptz. The @ names are driver parameters. Each replica locks the same grant row. Evaluate database time after acquiring the lock so the deadline check uses the current time rather than a timestamp from before the wait. The materialized CTE provides a single collection timestamp for both the expiry check and the returned receipt. A successful committed increment uses up one reservation. If no row is returned, the capture is denied.
Apply grant changes and revocations through the same authority, incrementing the version when the scope or lifetime changes. Include lock waits in the capture budget. Using database time prevents replica clocks from making independent decisions about when reservations expire.
Reservations are not refunded after failed redaction or storage. This approach can result in fewer than ten samples but will never authorize more than ten captures. An ambiguous reservation response may mean capacity was consumed. Do not reset the counter when retrying. Reuse a sample ID for evidence write retries, keeping the original timestamps.
The reservation is the authorization point. Revocation stops new reservations but does not retroactively cancel any that have already been issued. The producer must complete its work within the short budget and the collection deadline. If you need to immediately revoke in-flight capture, the evidence-admission service must check the current grant state before committing.
Carry absolute expiry through queues and copies
Every sample needs explicit lifecycle fields. The sample created above has this shape:
public sealed record EvidenceSample(
Guid SampleId, Guid GrantId, int GrantVersion, Guid OperationId,
DateTimeOffset CollectedAtUtc, DateTimeOffset ExpiresAtUtc,
byte[] Payload);
GrantId and GrantVersion identify the authorization used, even if the live grant changes or is revoked later. Preserve that authorized version for audit purposes.
Suppose reservation admission happens at 10:00 UTC and the evidence lifetime is twenty-four hours. ExpiresAtUtc will be 10:00 UTC the next day. If a queue delivers the redacted payload at 09:50 the next morning, the destination has ten minutes left, not another full day. Any arrival after 10:00 must be rejected.
The destination should compute any storage TTL based on the original expiry minus the current time. Apply the absolute deadline on reads, since a scheduled deletion sweep may run later. Clock synchronization and destination-side time checks are still needed. Producer-side checks alone cannot prevent delayed arrivals.
Stopping collection after thirty minutes does not delete accepted samples, and deleting from the active store does not remove all exported copies. Maintain a copy register with concrete enforcement and clear ownership:
| Copy | Required control | Owner |
| --- | --- | --- |
| Exporter buffers, configured Collector queues, routine backend | Approved fields before enqueue, reviewed retention and access at each destination | Service and telemetry-platform owners |
| Restricted sample and any sending queue | Original sample ID and absolute expiry, no expired admission or investigator reads, deletion mechanism | Evidence-service owner |
| Download, alert attachment, incident document | Authorized destination, readers, expiry, and tracked deletion or approved preservation | Named investigation owner |
| Production-derived evaluation case and outputs | Separate reuse authorization and lifecycle with traceable origin | Dataset owner |
A dashboard role is insufficient if a routine operator can still access evidence through an API or export permission. Also verify service identities and administrator access. Placing data in a separate table only restricts access if the backend enforces it. If a dashboard forwards content to an AI diagnosis service, include that destination in the register.
Backups, replicas, and preservation requirements need a defined lifecycle outside the active-store sweep. If an unexpected copy appears, contain the recording path and involve the responsible incident team in its resolution. Disabling the source flag does not dispose of existing evidence.
Treat reuse for evaluation as a new authorization
A production failure can become a regression case, but its investigation grant does not authorize keeping the dataset indefinitely. Prefer using a synthetic case that preserves the failure mechanism without including the original ticket.
If real content is needed, separately approve its new purpose, readers, destination, and expiry. Maintain lineage to the source sample and record the new authorization. Copying into CI or a golden dataset must not silently remove the original deadline.
A judge model is another possible destination. Its explanations and reviewer comments may quote the input and require content controls. A numeric score with approved labels can go into routine telemetry, but explanations must remain restricted. Versioning a dataset does not extend retention permission.
Verify failure paths before enabling real capture
Use distinct synthetic markers in messages, policy excerpts, tool payloads, conversation IDs, tool names, and provider exceptions. Include a fictional email address and a fake credential. Inspect spans, status descriptions, events, logs, queues, backend queries, and exports at their destinations.
| Exercise | Required observation |
| --- | --- |
| Provider exception with content capture disabled | Raw wrapper status and a configured logger can receive the marker. Test and control each path independently before export. |
| Environment flag accidentally enabled | Explicit wrapper setting still excludes message capture. Conversation IDs, tool identities, other instrumentation and logs are checked separately. |
| Wrong tenant, operation ID or type, stale version, revoked or expired grant | No reservation or content write. |
| Many replicas compete for ten remaining slots | At most ten committed reservations across the whole deployment. |
| Redaction fails, payload is oversized, or evidence storage is unavailable | No evidence sample and no fallback containing raw text. |
| Evidence-write retry after a lost acknowledgement | Same sample ID, grant version, and original expiry, with no new retention clock. |
| Reservation commits but its acknowledgement is lost | Capacity stays consumed. No sample is written without a confirmed reservation receipt. |
| Queue arrival or read after absolute expiry | Admission or read denied, even before the deletion sweep runs. |
| Routine operator uses the query API or export route | Investigation content remains inaccessible. |
Marker tests detect known paths, not every possible private sentence. Repeat these tests after any SDK, exporter, Collector, or evaluation changes. For a real incident, also test the copy register and deletion process. A successful source-side unit test does not confirm backend access or erasure.
When to use this approach
Use this approach when production debugging requires selected user or business content across multiple service instances. Start with a single feature, a versioned grant, a shared reservation authority, and a restricted store that enforces absolute expiry. The existing observability walkthrough covers instrumentation setup, and trust-boundary guidance provides the broader authorization model.
When not to use this approach
Experiments with only synthetic data may need simpler controls. Mandatory audit evidence and investigations that require unredacted records need a separately designed evidence process with the necessary availability, preservation, and retention guarantees. This best-effort diagnostic path is a single application control, not a complete privacy or governance program.
Related reading
- Define what AI telemetry may contain before recording content
- Observability for Agents with OpenTelemetry, Aspire, and Application Insights
- Trust Boundaries Around AI Features
Sources
- Microsoft Learn: EnableSensitiveData defaults and environment-variable precedence
- .NET Extensions source: OpenTelemetryChatClient at the 10.10.0 package revision
- .NET Extensions source: Error status and exception recording at the same revision
- OpenTelemetry: Handling sensitive data
- OpenTelemetry: Baggage security considerations
- OpenTelemetry Collector Contrib: Redaction processor behavior
- PostgreSQL 18: Read Committed isolation and concurrent updates
- PostgreSQL 18: UPDATE and RETURNING
- PostgreSQL 18: Current-time functions and clock_timestamp
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.
2 reactions
What broke when I moved Grafana Faro to OpenTelemetry JS 3.0
TL;DR: I opened the three PRs that move Grafana Faro's web SDK onto OpenTelemetry JS SDK 3.0. The version bump was the easy part. What took the time was a deep import the new exports maps reject, and then the tests. Jest was quietly running the Node build, and a disabled XHR instrumentation turned out to still be making spans. All three PRs are open and waiting on review.
!The Faro epic on GitHub, Migrate to OpenTelemetry JS SDK 3.x, with the plan split into three steps
Why Faro had to move at all
OpenTelemetry JS SDK 3.0, experimental 0.300 and API 1.10 came out on Oct 20. They only work with each other, so you can't take one and leave the rest. Faro was on 2.x, 0.223 and API 1.9.
That sounds like something you can sit on for a while, and for the stable 2.x packages you can, they're supported until about Oct 2027. The problem showed up somewhere else. The contrib browser instrumentations (document load, user interaction, long task) had already moved to core ^3.0.0. Install one of those next to @grafana/faro-web-tracing and npm resolves @opentelemetry/api to 1.9.1, cause Faro asks for <1.10.0. Instrumentation 0.300 imports logs from the API, and 1.9.1 doesn't have it. So your app breaks on a package you didn't even touch.
The Faro team wrote this up as an epic with the plan. I picked up the steps.
Three PRs, stacked
I could have done it in one PR. It would have been one big diff touching about 30 files, and nobody wants to review that.
| PR | What it does | Ships in |
| --- | --- | --- |
| #2358 | Moves tracing setup to @opentelemetry/sdk-trace | any 2.x minor |
| #2363 | Removes deep imports into OTel packages | any 2.x minor |
| #2364 | The actual 3.0 / 0.300 / 1.10 bump | open decision |
The first two change nothing for users and work on 2.x, so they can merge whenever. Only the last one raises what your build needs. Stacking them means the risky diff is small and the boring ones can go first.
WebTracerProvider is gone
SDK 3.x drops @opentelemetry/sdk-trace-web and @opentelemetry/sdk-trace-base. Everything Faro used from them is already in @opentelemetry/sdk-trace 2.12, which is why this step can land on 2.x.
The one real change is that WebTracerProvider.register() used to do several things for you. With the plain TracerProvider you do them yourself, in order:
- provider.register({
- propagator: new FaroPausablePropagator(/* ... */),
- contextManager: options.contextManager,
- });
+ trace.setGlobalTracerProvider(provider);
+ propagation.setGlobalPropagator(new FaroPausablePropagator(/* ... */));
+
+ const contextManager = options.contextManager ?? new StackContextManager();
+ contextManager.enable();
+ context.setGlobalContextManager(contextManager);
The order matters more than it looks. The context manager has to be enabled before it's registered, and a second Faro instance must not register again over the first. I wrote tests for each of those paths, cause that's exactly the kind of thing that works on the happy path and breaks for the one app that passes its own propagator.
Side effect I didn't plan for: the tracing IIFE went from 90,474 to 87,299 bytes minified. Dropping a package you don't use is a nice way to lose 3 KB.
The exports map says no
This is the one that would have broken the upgrade if you just bumped versions.
All the 0.300 packages declare an exports map in package.json. Faro had imports straight into build/src of @opentelemetry/otlp-transformer, for the OTLP payload types and for createExportTraceServiceRequest and JSON_ENCODER. With an exports map, any subpath that isn't listed fails to resolve. It doesn't matter if the file is sitting right there.
!Node fails with ERR_PACKAGE_PATH_NOT_EXPORTED and tsc fails with TS2307 when importing from otlp-transformer 0.300's build/src
Pointing the paths at the new dist/ layout doesn't help either. Unlisted is unlisted.
So I moved the OTLP types (IResourceSpans, IKeyValue, ESpanKind and the rest) into @grafana/faro-core, and swapped the exporter over to the public JsonTraceSerializer:
- import { JSON_ENCODER } from '@opentelemetry/otlp-transformer/build/src/common/utils';
- import { createExportTraceServiceRequest } from '@opentelemetry/otlp-transformer/build/src/trace/internal';
+ import { JsonTraceSerializer } from '@opentelemetry/otlp-transformer';
- const traceEvent = createExportTraceServiceRequest(spans, JSON_ENCODER);
+ const traceEvent: TraceEvent = JSON.parse(
+ new TextDecoder().decode(JsonTraceSerializer.serializeRequest(spans))
+ );
That's one extra encode and parse per batch. I don't love it, though it's the public API in both 0.223 and 0.300 and the payload that goes out is the same. A snapshot test of the trace payload and the derived faro.tracing.* event now pins that down.
One detail I'd point out to anyone doing the same thing. I didn't copy ESpanKind as a TypeScript enum. TraceEvent is public through faro.api.pushTraces, and enums from two different declarations aren't assignable to each other. If Faro shipped its own enum, any app building payloads with otlp-transformer's enum would stop compiling. A const object with a numeric union type keeps both working.
This step also took @opentelemetry/otlp-transformer out of core and the OTLP transport, they only used its types. Without that, every Faro user would pick up 0.300's engines.node >=22.15.0, not just the ones using tracing.
The bump, and what it found
The version change itself is a few lines in package.json. core, resources and sdk-trace to ^3.0.0, the experimental packages to ^0.300.0, the API to ^1.10.0. Typecheck passed without a single change. That was honestly a bit suspicious.
Jest was running the Node build
Then Jest. Under Jest 30 with jsdom, OTel's packages resolve to their Node builds, not the browser ones. Nothing fails. You just test the wrong code. This is a one-line test that prints SDK_INFO from @opentelemetry/core 3.0, run once without the resolver and once with it:
!The same jsdom test reports telemetry.sdk.language nodejs without the resolver and webjs with it, and passes both times
SDK_INFO reporting nodejs inside a browser test environment is the tell. The 3.x migration guide has a Jest resolver for this, and adding it flips it to webjs. If you have your own Jest setup with OTel 3.x and jsdom, check this before you trust your green tests.
A disabled instrumentation that kept tracing
Running the tests against the real XMLHttpRequestInstrumentation (the old ones mocked it) turned up a bug that wasn't about 3.0 at all. FaroXhrInstrumentation overrides _patchOpen, and the override didn't check _enabled like the parent does. disable() leaves open patched, so a disabled Faro XHR instrumentation kept creating spans.
!The new XHR test fails on the #2363 tree with one span after disable(), and all three tests pass on #2364
It's a one-line check. It's also the kind of bug that lives for years cause the unit tests mock the exact thing that would catch it.
Size and tests
!yarn build output for the tracing IIFE: 74.82 kB and 22.19 kB gzipped on #2363, 76.68 kB and 22.55 kB on #2364
The tracing bundle grows from 74.8 kB to 76.7 kB (22.1 to 22.5 kB gzipped) against #2363. The web-sdk and core bundles stay the same size, and there's no Node platform code in the browser bundle.
On the #2364 head the full suite passes, 1,218 unit tests across seven packages with one skipped.
!yarn quality:test on #2364: every package passes, 204, 56, 82, 193, 646, 31 and 6 tests
What I checked outside the repo
Unit tests only tell you so much for a change like this, so I packed the packages and tried them in real apps:
- A webpack 5.54 app and a Vite 7 app both build and run. Fetch and XHR spans get exported with telemetry.sdk.language set to webjs, and the faro.tracing.* events still go out.
- The .d.ts files typecheck with both bundler and node16 module resolution.
- On the other end, Alloy's faro.receiver uses the collector's ptrace.JSONUnmarshaler, so span attributes with object, array and bytes values still parse.
I also ran my own otel-js-upgrade codemod as a dry run on the #2363 tree. It proposed the same version bumps and no code changes. Which is the right answer, and kind of the point of this post: the parts that took time are the ones no codemod can see.
What's still open
The 3.0 PR has no ! in the title on purpose. Whether this ships as Faro v3 or a 2.x minor is still an open call in the epic. Faro's own API doesn't change, but the floor for your build does: webpack 5.54+, Jest 29.4+ or Parcel with packageExports, and Node 22.15+ for installs. Those are old versions to still be on, though a minor release breaking someone's build is still a minor release breaking someone's build.
There's one thing I left alone. sendFaroEvents turns attribute values into strings with String(), so array and object values come out as [object Object] in the derived events. Arrays already did that on 2.x, 3.x just lets objects through too. That's a small follow up if the maintainers want JSON there.
!PR #2364 on GitHub, the OpenTelemetry JS SDK 3.0 upgrade, open and waiting on review
After this, the next step is #2340, replacing Faro's fetch and XHR instrumentations with the ones from the OpenTelemetry Browser SIG. That changes trace data, so it's a bigger conversation.
If you're on Faro today
Until the upgrade ships, if you add contrib browser instrumentations next to Faro, pin the last versions on 2.x:
npm i @opentelemetry/instrumentation-document-load@0.68.0 \
@opentelemetry/instrumentation-user-interaction@0.67.0 \
@opentelemetry/instrumentation-long-task@0.67.0
That keeps @opentelemetry/api on one version and your app out of the logs import error.
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
Privacy Boundaries for AI Telemetry Turning off prompt capture is not enough to enforce a complete telemetry recording policy. Exceptions can still include customer text in spans. If an investigation is limited to ten samples, it can end up with forty if four replicas each track their own counters. Queued samples can exceed their intended retention if the destination resets the expiry upon arrival. The recording-policy tip establishes what to allow. This article focuses on enforcement: inspect the actual SDK output, authorize capture with a narrow scope, reserve quota across replicas, and carry an absolute expiry through every copy of evidence. The example is a support assistant that reads a private ticket, fetches a refund policy, calls an eligibility tool, and drafts a reply. An investigation requires the policy excerpt and the generated reply, but should not collect the ticket or account-tool payload. Verify what the SDK actually records Keep ordinary message capture explicitly disabled. This composition fragment assumes an existing provider client and uses Microsoft.Extensions.AI 10.10.0: using Microsoft.Extensions.AI; IChatClient client = new ChatClientBuilder(providerClient) .UseOpenTelemetry( sourceName: "SupportReply.AI", configure: telemetry => telemetry.EnableSensitiveData = false) .Build(); Setting this option explicitly overrides OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT for this wrapper. This is not a telemetry allowlist. Reviewing the 10.10.0 source shows there are additional paths: | Recorded value | Behavior with sensitive capture disabled | | --- | --- | | Error status description | The error helper passes the exception message to the activity status. | | Exception log | When a logger is configured and warnings are enabled, the helper passes the exception to ILogger, independently of span export. | | Conversation identifier | The wrapper records ChatOptions.ConversationId when supplied and the activity requests data. | | Tool definitions | It serializes tool definitions with optional properties excluded. Tool identities still need review. | For example, if a provider exception includes a synthetic customer marker, that marker remains in Activity.StatusDescription even if raw message capture is disabled. The conversation identifier can also link a trace to a private application record. For a strict routine destination, use application-owned instrumentation built from approved values. Do not connect its tracer provider to the wrapper’s source until the source’s output complies with policy. Review the configured logger and its providers separately. Excluding the activity source does not prevent exception logging. Catching exceptions outside the wrapper does not remove a completed span or an exception already passed to a logger. Treat any allowed sanitization as specific to the exact fields and signal paths it covers. Removing message attributes alone does not address status descriptions, events, resource attributes, log bodies, and other instrumentation. Collector redaction is an extra safeguard, but it cannot reverse data already sent to an exporter or persistent queue. These are observations specific to this version, not a guarantee for all SDK releases. Repeat inspections and marker checks with every upgrade. Keep routine evidence useful and bounded An operator investigating an ineligible refund often starts with operational evidence: | Investigation question | Approved routine evidence | | --- | --- | | Which behavior was deployed? | Build identity, prompt version, configured model alias, policy version | | Did retrieval return context? | Retrieval outcome, result count, context size | | Did the eligibility tool complete? | Registered tool name, duration, controlled outcome code | | What happened to the reply? | Completion outcome, token usage when available, application validation result | A result count alone does not establish relevance or provide sufficient evidence. To determine if the model saw a conflicting refund clause, you may need separately authorized access to content. Constrain not only field names but also the values. Use controlled failure codes. Exception messages, document titles, URLs, and log scopes can leak customer details or secrets. Keep record lookups behind authorized application access. Opaque IDs and hashes still allow correlation and should not be used in high-cardinality metric dimensions. Review OpenTelemetry Baggage separately, since propagation can expose it to downstream services via HTTP headers. Exclude customer content and credentials, treat incoming baggage as untrusted, and configure propagation at external boundaries. Make diagnostic capture require a versioned grant Keep ordinary capture disabled during an incident. Use a dedicated capture service to select permitted fields and write only to a restricted evidence store. A global content flag would gather unrelated conversations as well. An authorized service owner can issue a grant with the following limits. These values are illustrative, not SDK defaults or universal limits: | Property | Authorized decision | | --- | --- | | Purpose and scope | Refund-policy investigation for one tenant, the support-reply operation type, and an explicit set of operation IDs | | Fixed capture schema | Policy excerpt and generated reply. Exclude the private ticket and account-tool payload | | Collection window | Thirty minutes. Revoke earlier if necessary | | Shared quota | Ten reservations across all replicas, with each payload at most 16 KiB after redaction and UTF-8 serialization | | Destination and readers | Restricted evidence store for assigned investigators | | Evidence lifetime | Twenty-four hours from collection, independently of the collection window | An operation ID comes from trusted application context. It is not a model argument or an investigation header used for authorization. If the incident is linked to a conversation, resolve authorized operations on the server and list those IDs. A broad tenant-wide capture requires separate justification. This application supports a single fixed diagnostic schema: policy excerpt and generated reply. A grant authorizes only this schema and cannot select arbitrary fields. Adding or changing fields requires a separately reviewed schema and capture path with new grants. Assign each grant a version. Changes to scope, collection deadline, evidence lifetime, or byte limit invalidate old authorizations at reservation time. The capture writer cannot expand a grant or read evidence. Investigator access is a separate role. Put selection, reservation, and expiry on one path The following C# code shows the enforcement boundary inside a capture service. The application-specific collaborators described below are not features provided by OpenTelemetry: public sealed record CaptureGrant( Guid Id, int Version, string TenantId, string OperationType, IReadOnlySet<Guid> OperationIds, DateTimeOffset CollectUntilUtc, TimeSpan EvidenceTtl, int MaxUtf8Bytes); public async Task TryCaptureAsync( CaptureGrant grant, TrustedOperation operation, CancellationToken ct) { if (grant.TenantId != operation.TenantId || grant.OperationType != operation.Type || !grant.OperationIds.Contains(operation.Id)) return; using var budget = CancellationTokenSource.CreateLinkedTokenSource(ct); budget.CancelAfter(TimeSpan.FromSeconds(2)); var slot = await reservations.TryReserveAsync(grant, operation, budget.Token); if (slot is null) return; // This type exposes only the two fields authorized by this design. var selected = new DiagnosticFields(operation.PolicyExcerpt, operation.Reply); byte[]? safePayload = redactor.TrySerializeUtf8( selected, grant.MaxUtf8Bytes, budget.Token); if (safePayload is null || safePayload.Length > grant.MaxUtf8Bytes) return; var now = clock.GetUtcNow(); var expiresAt = slot.CollectedAtUtc + grant.EvidenceTtl; if (now >= slot.CollectUntilUtc || now >= expiresAt) return; var sample = new EvidenceSample(slot.SampleId, grant.Id, grant.Version, operation.Id, slot.CollectedAtUtc, expiresAt, safePayload); await evidence.WriteIfFreshAsync(sample, budget.Token); } grant is an immutable, server-authorized snapshot loaded after verifying the investigation purpose and the requesting identity. TrustedOperation is provided by the application. The first guard checks for context mismatches before selecting content. reservations revalidates the current grant, including its version, scope, revocation status, deadline, and shared quota. It returns a unique sample ID and database timestamp only after committing the reservation. CollectedAtUtc records the time the reservation was admitted, before payload selection and redaction, ensuring that processing or transport delays do not extend retention. redactor serializes only DiagnosticFields, respects the byte and cancellation budgets, removes required recognized secrets, and returns no payload if it cannot verify removal. This does not guarantee that arbitrary text is non-sensitive, so the destination must be authorized for any remaining content. Do not serialize the entire operation and then try to remove private fields afterward. evidence accepts the same sample ID idempotently and checks the absolute expiry again when admitting the sample. The fragment omits exception handling. Its calling capture boundary must catch operational exceptions from these collaborators, including capture timeouts, without failing the user request. On failed redaction or an operational failure, skip capture and record only a controlled outcome such as capture_skipped. Do not log the payload or exception text as a fallback. The example’s two-second budget requires all collaborators to honor cancellation and keep their work bounded. Reserve quota atomically across replicas A separate read of the remaining quota followed by an unconditional write races. In PostgreSQL, use a short transaction: acquire a lock on the grant row, check the deadline, and then perform a conditional increment: BEGIN; SELECT id FROM capture_grants WHERE id = @grant_id FOR UPDATE; WITH admission AS MATERIALIZED ( SELECT clock_timestamp() AS collected_at_utc ) UPDATE capture_grants SET samples_used = samples_used + 1 FROM admission WHERE id = @grant_id AND version = @version AND tenant_id = @tenant_id AND operation_type = @operation_type AND @operation_id = ANY(operation_ids) AND revoked_at IS NULL AND collect_until_utc > admission.collected_at_utc AND samples_used < max_samples RETURNING admission.collected_at_utc, collect_until_utc; COMMIT; Here, id is the primary key, operation_ids is a UUID array, and timestamps use timestamptz. The @ names are driver parameters. Each replica locks the same grant row. Evaluate database time after acquiring the lock so the deadline check uses the current time rather than a timestamp from before the wait. The materialized CTE provides a single collection timestamp for both the expiry check and the returned receipt. A successful committed increment uses up one reservation. If no row is returned, the capture is denied. Apply grant changes and revocations through the same authority, incrementing the version when the scope or lifetime changes. Include lock waits in the capture budget. Using database time prevents replica clocks from making independent decisions about when reservations expire. Reservations are not refunded after failed redaction or storage. This approach can result in fewer than ten samples but will never authorize more than ten captures. An ambiguous reservation response may mean capacity was consumed. Do not reset the counter when retrying. Reuse a sample ID for evidence write retries, keeping the original timestamps. The reservation is the authorization point. Revocation stops new reservations but does not retroactively cancel any that have already been issued. The producer must complete its work within the short budget and the collection deadline. If you need to immediately revoke in-flight capture, the evidence-admission service must check the current grant state before committing. Carry absolute expiry through queues and copies Every sample needs explicit lifecycle fields. The sample created above has this shape: public sealed record EvidenceSample( Guid SampleId, Guid GrantId, int GrantVersion, Guid OperationId, DateTimeOffset CollectedAtUtc, DateTimeOffset ExpiresAtUtc, byte[] Payload); GrantId and GrantVersion identify the authorization used, even if the live grant changes or is revoked later. Preserve that authorized version for audit purposes. Suppose reservation admission happens at 10:00 UTC and the evidence lifetime is twenty-four hours. ExpiresAtUtc will be 10:00 UTC the next day. If a queue delivers the redacted payload at 09:50 the next morning, the destination has ten minutes left, not another full day. Any arrival after 10:00 must be rejected. The destination should compute any storage TTL based on the original expiry minus the current time. Apply the absolute deadline on reads, since a scheduled deletion sweep may run later. Clock synchronization and destination-side time checks are still needed. Producer-side checks alone cannot prevent delayed arrivals. Stopping collection after thirty minutes does not delete accepted samples, and deleting from the active store does not remove all exported copies. Maintain a copy register with concrete enforcement and clear ownership: | Copy | Required control | Owner | | --- | --- | --- | | Exporter buffers, configured Collector queues, routine backend | Approved fields before enqueue, reviewed retention and access at each destination | Service and telemetry-platform owners | | Restricted sample and any sending queue | Original sample ID and absolute expiry, no expired admission or investigator reads, deletion mechanism | Evidence-service owner | | Download, alert attachment, incident document | Authorized destination, readers, expiry, and tracked deletion or approved preservation | Named investigation owner | | Production-derived evaluation case and outputs | Separate reuse authorization and lifecycle with traceable origin | Dataset owner | A dashboard role is insufficient if a routine operator can still access evidence through an API or export permission. Also verify service identities and administrator access. Placing data in a separate table only restricts access if the backend enforces it. If a dashboard forwards content to an AI diagnosis service, include that destination in the register. Backups, replicas, and preservation requirements need a defined lifecycle outside the active-store sweep. If an unexpected copy appears, contain the recording path and involve the responsible incident team in its resolution. Disabling the source flag does not dispose of existing evidence. Treat reuse for evaluation as a new authorization A production failure can become a regression case, but its investigation grant does not authorize keeping the dataset indefinitely. Prefer using a synthetic case that preserves the failure mechanism without including the original ticket. If real content is needed, separately approve its new purpose, readers, destination, and expiry. Maintain lineage to the source sample and record the new authorization. Copying into CI or a golden dataset must not silently remove the original deadline. A judge model is another possible destination. Its explanations and reviewer comments may quote the input and require content controls. A numeric score with approved labels can go into routine telemetry, but explanations must remain restricted. Versioning a dataset does not extend retention permission. Verify failure paths before enabling real capture Use distinct synthetic markers in messages, policy excerpts, tool payloads, conversation IDs, tool names, and provider exceptions. Include a fictional email address and a fake credential. Inspect spans, status descriptions, events, logs, queues, backend queries, and exports at their destinations. | Exercise | Required observation | | --- | --- | | Provider exception with content capture disabled | Raw wrapper status and a configured logger can receive the marker. Test and control each path independently before export. | | Environment flag accidentally enabled | Explicit wrapper setting still excludes message capture. Conversation IDs, tool identities, other instrumentation and logs are checked separately. | | Wrong tenant, operation ID or type, stale version, revoked or expired grant | No reservation or content write. | | Many replicas compete for ten remaining slots | At most ten committed reservations across the whole deployment. | | Redaction fails, payload is oversized, or evidence storage is unavailable | No evidence sample and no fallback containing raw text. | | Evidence-write retry after a lost acknowledgement | Same sample ID, grant version, and original expiry, with no new retention clock. | | Reservation commits but its acknowledgement is lost | Capacity stays consumed. No sample is written without a confirmed reservation receipt. | | Queue arrival or read after absolute expiry | Admission or read denied, even before the deletion sweep runs. | | Routine operator uses the query API or export route | Investigation content remains inaccessible. | Marker tests detect known paths, not every possible private sentence. Repeat these tests after any SDK, exporter, Collector, or evaluation changes. For a real incident, also test the copy register and deletion process. A successful source-side unit test does not confirm backend access or erasure. When to use this approach Use this approach when production debugging requires selected user or business content across multiple service instances. Start with a single feature, a versioned grant, a shared reservation authority, and a restricted store that enforces absolute expiry. The existing observability walkthrough covers instrumentation setup, and trust-boundary guidance provides the broader authorization model. When not to use this approach Experiments with only synthetic data may need simpler controls. Mandatory audit evidence and investigations that require unredacted records need a separately designed evidence process with the necessary availability, preservation, and retention guarantees. This best-effort diagnostic path is a single application control, not a complete privacy or governance program. Related reading - Define what AI telemetry may contain before recording content - Observability for Agents with OpenTelemetry, Aspire, and Application Insights - Trust Boundaries Around AI Features Sources - Microsoft Learn: EnableSensitiveData defaults and environment-variable precedence - .NET Extensions source: OpenTelemetryChatClient at the 10.10.0 package revision - .NET Extensions source: Error status and exception recording at the same revision - OpenTelemetry: Handling sensitive data - OpenTelemetry: Baggage security considerations - OpenTelemetry Collector Contrib: Redaction processor behavior - PostgreSQL 18: Read Committed isolation and concurrent updates - PostgreSQL 18: UPDATE and RETURNING - PostgreSQL 18: Current-time functions and clock_timestamp
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.
What broke when I moved Grafana Faro to OpenTelemetry JS 3.0 TL;DR: I opened the three PRs that move Grafana Faro's web SDK onto OpenTelemetry JS SDK 3.0. The version bump was the easy part. What took the time was a deep import the new exports maps reject, and then the tests. Jest was quietly running the Node build, and a disabled XHR instrumentation turned out to still be making spans. All three PRs are open and waiting on review. !The Faro epic on GitHub, Migrate to OpenTelemetry JS SDK 3.x, with the plan split into three steps Why Faro had to move at all OpenTelemetry JS SDK 3.0, experimental 0.300 and API 1.10 came out on Oct 20. They only work with each other, so you can't take one and leave the rest. Faro was on 2.x, 0.223 and API 1.9. That sounds like something you can sit on for a while, and for the stable 2.x packages you can, they're supported until about Oct 2027. The problem showed up somewhere else. The contrib browser instrumentations (document load, user interaction, long task) had already moved to core ^3.0.0. Install one of those next to @grafana/faro-web-tracing and npm resolves @opentelemetry/api to 1.9.1, cause Faro asks for <1.10.0. Instrumentation 0.300 imports logs from the API, and 1.9.1 doesn't have it. So your app breaks on a package you didn't even touch. The Faro team wrote this up as an epic with the plan. I picked up the steps. Three PRs, stacked I could have done it in one PR. It would have been one big diff touching about 30 files, and nobody wants to review that. | PR | What it does | Ships in | | --- | --- | --- | | #2358 | Moves tracing setup to @opentelemetry/sdk-trace | any 2.x minor | | #2363 | Removes deep imports into OTel packages | any 2.x minor | | #2364 | The actual 3.0 / 0.300 / 1.10 bump | open decision | The first two change nothing for users and work on 2.x, so they can merge whenever. Only the last one raises what your build needs. Stacking them means the risky diff is small and the boring ones can go first. WebTracerProvider is gone SDK 3.x drops @opentelemetry/sdk-trace-web and @opentelemetry/sdk-trace-base. Everything Faro used from them is already in @opentelemetry/sdk-trace 2.12, which is why this step can land on 2.x. The one real change is that WebTracerProvider.register() used to do several things for you. With the plain TracerProvider you do them yourself, in order: - provider.register({ - propagator: new FaroPausablePropagator(/* ... */), - contextManager: options.contextManager, - }); + trace.setGlobalTracerProvider(provider); + propagation.setGlobalPropagator(new FaroPausablePropagator(/* ... */)); + + const contextManager = options.contextManager ?? new StackContextManager(); + contextManager.enable(); + context.setGlobalContextManager(contextManager); The order matters more than it looks. The context manager has to be enabled before it's registered, and a second Faro instance must not register again over the first. I wrote tests for each of those paths, cause that's exactly the kind of thing that works on the happy path and breaks for the one app that passes its own propagator. Side effect I didn't plan for: the tracing IIFE went from 90,474 to 87,299 bytes minified. Dropping a package you don't use is a nice way to lose 3 KB. The exports map says no This is the one that would have broken the upgrade if you just bumped versions. All the 0.300 packages declare an exports map in package.json. Faro had imports straight into build/src of @opentelemetry/otlp-transformer, for the OTLP payload types and for createExportTraceServiceRequest and JSON_ENCODER. With an exports map, any subpath that isn't listed fails to resolve. It doesn't matter if the file is sitting right there. !Node fails with ERR_PACKAGE_PATH_NOT_EXPORTED and tsc fails with TS2307 when importing from otlp-transformer 0.300's build/src Pointing the paths at the new dist/ layout doesn't help either. Unlisted is unlisted. So I moved the OTLP types (IResourceSpans, IKeyValue, ESpanKind and the rest) into @grafana/faro-core, and swapped the exporter over to the public JsonTraceSerializer: - import { JSON_ENCODER } from '@opentelemetry/otlp-transformer/build/src/common/utils'; - import { createExportTraceServiceRequest } from '@opentelemetry/otlp-transformer/build/src/trace/internal'; + import { JsonTraceSerializer } from '@opentelemetry/otlp-transformer'; - const traceEvent = createExportTraceServiceRequest(spans, JSON_ENCODER); + const traceEvent: TraceEvent = JSON.parse( + new TextDecoder().decode(JsonTraceSerializer.serializeRequest(spans)) + ); That's one extra encode and parse per batch. I don't love it, though it's the public API in both 0.223 and 0.300 and the payload that goes out is the same. A snapshot test of the trace payload and the derived faro.tracing.* event now pins that down. One detail I'd point out to anyone doing the same thing. I didn't copy ESpanKind as a TypeScript enum. TraceEvent is public through faro.api.pushTraces, and enums from two different declarations aren't assignable to each other. If Faro shipped its own enum, any app building payloads with otlp-transformer's enum would stop compiling. A const object with a numeric union type keeps both working. This step also took @opentelemetry/otlp-transformer out of core and the OTLP transport, they only used its types. Without that, every Faro user would pick up 0.300's engines.node >=22.15.0, not just the ones using tracing. The bump, and what it found The version change itself is a few lines in package.json. core, resources and sdk-trace to ^3.0.0, the experimental packages to ^0.300.0, the API to ^1.10.0. Typecheck passed without a single change. That was honestly a bit suspicious. Jest was running the Node build Then Jest. Under Jest 30 with jsdom, OTel's packages resolve to their Node builds, not the browser ones. Nothing fails. You just test the wrong code. This is a one-line test that prints SDK_INFO from @opentelemetry/core 3.0, run once without the resolver and once with it: !The same jsdom test reports telemetry.sdk.language nodejs without the resolver and webjs with it, and passes both times SDK_INFO reporting nodejs inside a browser test environment is the tell. The 3.x migration guide has a Jest resolver for this, and adding it flips it to webjs. If you have your own Jest setup with OTel 3.x and jsdom, check this before you trust your green tests. A disabled instrumentation that kept tracing Running the tests against the real XMLHttpRequestInstrumentation (the old ones mocked it) turned up a bug that wasn't about 3.0 at all. FaroXhrInstrumentation overrides _patchOpen, and the override didn't check _enabled like the parent does. disable() leaves open patched, so a disabled Faro XHR instrumentation kept creating spans. !The new XHR test fails on the #2363 tree with one span after disable(), and all three tests pass on #2364 It's a one-line check. It's also the kind of bug that lives for years cause the unit tests mock the exact thing that would catch it. Size and tests !yarn build output for the tracing IIFE: 74.82 kB and 22.19 kB gzipped on #2363, 76.68 kB and 22.55 kB on #2364 The tracing bundle grows from 74.8 kB to 76.7 kB (22.1 to 22.5 kB gzipped) against #2363. The web-sdk and core bundles stay the same size, and there's no Node platform code in the browser bundle. On the #2364 head the full suite passes, 1,218 unit tests across seven packages with one skipped. !yarn quality:test on #2364: every package passes, 204, 56, 82, 193, 646, 31 and 6 tests What I checked outside the repo Unit tests only tell you so much for a change like this, so I packed the packages and tried them in real apps: - A webpack 5.54 app and a Vite 7 app both build and run. Fetch and XHR spans get exported with telemetry.sdk.language set to webjs, and the faro.tracing.* events still go out. - The .d.ts files typecheck with both bundler and node16 module resolution. - On the other end, Alloy's faro.receiver uses the collector's ptrace.JSONUnmarshaler, so span attributes with object, array and bytes values still parse. I also ran my own otel-js-upgrade codemod as a dry run on the #2363 tree. It proposed the same version bumps and no code changes. Which is the right answer, and kind of the point of this post: the parts that took time are the ones no codemod can see. What's still open The 3.0 PR has no ! in the title on purpose. Whether this ships as Faro v3 or a 2.x minor is still an open call in the epic. Faro's own API doesn't change, but the floor for your build does: webpack 5.54+, Jest 29.4+ or Parcel with packageExports, and Node 22.15+ for installs. Those are old versions to still be on, though a minor release breaking someone's build is still a minor release breaking someone's build. There's one thing I left alone. sendFaroEvents turns attribute values into strings with String(), so array and object values come out as [object Object] in the derived events. Arrays already did that on 2.x, 3.x just lets objects through too. That's a small follow up if the maintainers want JSON there. !PR #2364 on GitHub, the OpenTelemetry JS SDK 3.0 upgrade, open and waiting on review After this, the next step is #2340, replacing Faro's fetch and XHR instrumentations with the ones from the OpenTelemetry Browser SIG. That changes trace data, so it's a bigger conversation. If you're on Faro today Until the upgrade ships, if you add contrib browser instrumentations next to Faro, pin the last versions on 2.x: npm i @opentelemetry/instrumentation-document-load@0.68.0 \ @opentelemetry/instrumentation-user-interaction@0.67.0 \ @opentelemetry/instrumentation-long-task@0.67.0 That keeps @opentelemetry/api on one version and your app out of the logs import error.
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…