You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/10/09 05:25:36 UTC
(skywalking-ai-sessionizer) branch remove-claude-code-otlp created (now 4781478)
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a change to branch remove-claude-code-otlp
in repository https://gitbox.apache.org/repos/asf/skywalking-ai-sessionizer.git
at 4781478 Remove the claude-code-otlp receiver
This branch includes the following new commits:
new 4781478 Remove the claude-code-otlp receiver
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(skywalking-ai-sessionizer) 01/01: Remove the claude-code-otlp receiver
Posted by wu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch remove-claude-code-otlp
in repository https://gitbox.apache.org/repos/asf/skywalking-ai-sessionizer.git
commit 478147830659459693d1239108b564277fd43857
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Fri Oct 9 12:12:00 2026 +0800
Remove the claude-code-otlp receiver
The receiver accepted what Claude Code's own exporter sent and kept
none of it. Every metric asz sends is derived from the landed files, so
it fed nothing. A Claude Code pointed at it lost the metrics only the
exporter has, such as cost and active time, which the SkyWalking OAP
reads when the exporter is pointed at the OAP directly.
The adapter, its configuration entry, its end-to-end check and its
pages are removed. A configuration written for 0.5.0 still names it.
That file still loads, and an enabled entry is skipped with a line that
says where to point the exporter instead.
---
CLAUDE.md | 1 -
README.md | 2 +-
asz.yaml | 8 -
cmd/asz/main.go | 101 +--------
cmd/asz/push.go | 4 +-
cmd/asz/refresh.go | 7 +-
docs/en/adapters/claude-code.md | 19 +-
docs/en/changes/changes.md | 17 +-
docs/en/formats/otlp.md | 8 +-
docs/en/guides/command-line-internals.md | 4 +-
docs/en/setup/configuration.md | 16 +-
docs/en/setup/export-otlp.md | 6 +
internal/adapters/claudecodeotlp/receiver.go | 259 ----------------------
internal/adapters/claudecodeotlp/receiver_test.go | 90 --------
internal/config/config.go | 60 ++---
internal/config/config_test.go | 65 ++++--
internal/metrics/capture_test.go | 5 +-
internal/metrics/metrics.go | 3 +-
internal/metrics/parity_test.go | 2 +-
tools/test/collector-check/main.go | 5 -
tools/test/e2e-collector.sh | 54 -----
21 files changed, 113 insertions(+), 623 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 9425f64..eb5ec27 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -68,7 +68,6 @@ internal/export/otlp/ sends landed files and rounds to an OpenTelemetr
internal/metrics/ derives the runtime's token metric from landed Session Data into the spool; collector side; testdata holds a redacted capture of the runtime's exporter
internal/repack/ re-cuts landed files into a new root under another budget
internal/adapters/claudecode/ the claude-code-local adapter
-internal/adapters/claudecodeotlp/ the claude-code-otlp adapter: a receiver for the runtime's own exporter, metrics into the spool
internal/adapters/claudecodeprovider/ the claude-code-provider adapter: the request and response bodies Claude Code writes, landed into their sessions
internal/adapters/mock/ the mock dialect: Session Data a scenario writes directly
internal/scenario/ scenarios: the model, the clock, the two writers, and each session's marker (collector side)
diff --git a/README.md b/README.md
index 63da9ef..05c1b7a 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,7 @@ adapter reports it as `unavailable` rather than approximating it.
| Runtime | Status | Collection |
| --- | --- | --- |
-| [Claude Code](docs/en/adapters/claude-code.md) | collection implemented | local files — no configuration required, and it works on history that already exists. The [plugin](docs/en/setup/claude-code-plugin.md) adds which files each shell command changed. The [receiver](docs/en/setup/configuration.md#the-receiver-adapter), `claude-code-otlp`, is off by default. It accepts what Claude Code's own OpenTelemetry exporter sends and keeps none of it, so a Claude Code set to export keeps working. |
+| [Claude Code](docs/en/adapters/claude-code.md) | collection implemented | local files — no configuration required, and it works on history that already exists. The [plugin](docs/en/setup/claude-code-plugin.md) adds which files each shell command changed. |
| Codex | planned | — |
| [LangChain / LangGraph](docs/en/adapters/langsmith.md) | collection implemented | a receiver, `langsmith-ingest`, the tracing client every LangChain application carries is pointed at with four environment variables; nothing changes in the application. It lands the conversation and what each model call was sent. The [plugin](docs/en/setup/langchain-plugin.md) adds which files each tool call changed. |
diff --git a/asz.yaml b/asz.yaml
index f3228a5..95d5805 100644
--- a/asz.yaml
+++ b/asz.yaml
@@ -78,14 +78,6 @@ adapters:
# repack re-cuts an existing root into a new one.
max_delta_bytes: 2097152
- # A listener for the runtime's own OpenTelemetry exporter. It accepts
- # metrics, logs and traces over gRPC or HTTP and keeps none of them, so a
- # Claude Code already set to export keeps working. The metrics asz sends
- # are derived from the landed files; see metrics below.
- - name: claude-code-otlp
- enabled: false
- listen: 127.0.0.1:4317
-
# The workspace change records the asz Claude Code plugin writes: which
# files each shell command changed, and each edit made inside a
# subagent, as git-style hunks. The plugin writes them beside Claude
diff --git a/cmd/asz/main.go b/cmd/asz/main.go
index bf30838..1054eea 100644
--- a/cmd/asz/main.go
+++ b/cmd/asz/main.go
@@ -19,7 +19,6 @@
package main
import (
- "errors"
"flag"
"fmt"
"os"
@@ -31,7 +30,6 @@ import (
"time"
"github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecode"
- "github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecodeotlp"
"github.com/apache/skywalking-ai-sessionizer/internal/adapters/langsmith"
"github.com/apache/skywalking-ai-sessionizer/internal/config"
"github.com/apache/skywalking-ai-sessionizer/internal/index"
@@ -170,8 +168,8 @@ func main() {
os.Exit(2)
}
- // The receiver adapters listen beside whatever else the command does.
- // Only the two that write for as long as the process runs host one:
+ // The receiver adapter listens beside whatever else the command does.
+ // Only the two that write for as long as the process runs host it:
// collect and server. A receiver lands what it is sent, and asz view
// only reads.
var local []config.Adapter
@@ -183,17 +181,8 @@ func main() {
case config.AdapterClaudeCodeLocal, config.AdapterChanges, config.AdapterClaudeCodeChanges,
config.AdapterClaudeCodeProvider:
local = append(local, ad)
- case config.AdapterClaudeCodeOTLP:
- if cmd != "collect" && cmd != "server" {
- continue
- }
- if *once {
- fmt.Fprintf(os.Stderr, "%s: the receiver runs in watch mode only; not started with -once\n", ad.Name)
- continue
- }
- if err := startReceiver(ad); err != nil {
- fatal(err)
- }
+ case config.RemovedClaudeCodeOTLP:
+ fmt.Fprintf(os.Stderr, "skipping %q: asz no longer receives Claude Code's exporter; point the exporter at the receiver that reads it, such as the SkyWalking OAP\n", ad.Name)
case config.AdapterLangSmithIngest:
if cmd != "collect" && cmd != "server" {
continue
@@ -205,11 +194,9 @@ func main() {
if err := startLangSmith(cfg, ad); err != nil {
fatal(err)
}
- // Unlike the metrics receiver, this one feeds the pipeline: what
- // it accepts becomes landed Session Data, which has to be parsed
- // and served like anything else. So it joins the adapters a pass
- // reads, and a root fed by it alone is a whole pipeline rather
- // than a push.
+ // What it accepts becomes landed Session Data, which has to be
+ // parsed and served like anything else. So it joins the adapters
+ // a pass reads, and a root fed by it alone is a whole pipeline.
local = append(local, ad)
default:
fmt.Fprintf(os.Stderr, "skipping unknown adapter %q\n", ad.Name)
@@ -234,14 +221,7 @@ func main() {
}
case "collect":
if len(local) == 0 {
- if receivers == 0 {
- fatal(fmt.Errorf("%s: no enabled adapter", cmd))
- }
- // No local source to land from, but a receiver is landing what
- // it is sent. Send that on, on the same period.
- if err := pushOnly(cfg); err != nil {
- fatal(err)
- }
+ fatal(fmt.Errorf("%s: no enabled adapter", cmd))
}
if err := cmdCollect(cfg, local, *once); err != nil {
fatal(err)
@@ -268,21 +248,6 @@ func main() {
}
}
-// receivers counts the receiver adapters started.
-var receivers int
-
-// startReceiver opens a claude-code-otlp receiver on its address and leaves
-// it listening for the life of the process.
-func startReceiver(ad config.Adapter) error {
- r := &claudecodeotlp.Receiver{Listen: ad.Listen}
- if err := r.Start(); err != nil {
- return err
- }
- receivers++
- fmt.Printf("receiver : %s on %s, gRPC and HTTP; metrics, logs and traces accepted and dropped\n", ad.Name, r.Addr())
- return nil
-}
-
// startLangSmith opens a langsmith-ingest receiver and leaves it listening for
// the life of the process. What it accepts waits in the inbox; the pipeline's
// next pass converts it, which is why this only starts the listener.
@@ -298,7 +263,6 @@ func startLangSmith(cfg *config.Config, ad config.Adapter) error {
if err := r.Start(); err != nil {
return err
}
- receivers++
guarded := "any key accepted"
if ad.Token != "" {
guarded = "a token is required"
@@ -830,55 +794,6 @@ func cmdCollect(cfg *config.Config, ads []config.Adapter, once bool) error {
return nil
}
-// pushOnly is what a configuration with no local source but a receiver
-// runs: the receiver lands what it is sent, and this sends it on, on the
-// collector's period. Without it a received record would sit in the root
-// until someone ran asz push by hand.
-func pushOnly(cfg *config.Config) error {
- zoneRoot, err := cfg.ResolvedRoot()
- if err != nil {
- return err
- }
- p, closeClient, err := newPusher(cfg, zoneRoot)
- if err != nil {
- return err
- }
- defer closeClient()
- if p == nil {
- // Nothing to send anywhere. The receivers keep the process alive.
- fmt.Println("export : none; set export.otlp.endpoint to send what the receiver lands")
- select {}
- }
- interval := config.Default().Adapters[0].Collector.Interval
- fmt.Printf("export : %s, every %s\n", cfg.Export.OTLP.Endpoint, interval)
- for {
- st, perr := p.Pass()
- if perr != nil && !errors.Is(perr, storage.ErrExportBusy) {
- fmt.Fprintf(os.Stderr, " error: %v\n", perr)
- }
- wait := interval
- if st != nil {
- for _, e := range st.Errors {
- fmt.Fprintf(os.Stderr, " error: %v\n", e)
- }
- if st.Rejected > 0 {
- // Taken by the receiver and dropped inside the request. The
- // files are marked sent and never go again, so this line is
- // the only place it can be seen.
- fmt.Fprintf(os.Stderr, " error: the receiver rejected %d record(s); they are not sent again\n", st.Rejected)
- }
- if st.Files > 0 || st.Metrics > 0 || st.Rejected > 0 {
- fmt.Printf("[%s] pushed=%d metrics=%d rejected=%d\n",
- time.Now().Format("15:04:05"), st.Files, st.Metrics, st.Rejected)
- }
- if st.Throttled && st.RetryAfter > wait {
- wait = st.RetryAfter
- }
- }
- time.Sleep(wait)
- }
-}
-
// newDeriver is the metrics derivation over a root. A single pass is the
// backfill path over history that already exists, with no later pass to
// derive what it left waiting, so it never waits for the next file of a
diff --git a/cmd/asz/push.go b/cmd/asz/push.go
index efd4bc8..2979641 100644
--- a/cmd/asz/push.go
+++ b/cmd/asz/push.go
@@ -154,8 +154,8 @@ func newPusher(cfg *config.Config, zoneRoot string) (*otlp.Pusher, func(), error
MaxBytesPerMinute: o.MaxBytesPerMinute,
NoLogs: !o.SendLogs(),
NoMetrics: !o.SendMetrics(),
- // The metrics spool concerns Claude Code whichever adapter filled it,
- // the local derivation or the runtime's own exporter.
+ // The metrics spool concerns Claude Code: everything in it is derived
+ // from Claude Code's landed files.
MetricsService: claudecode.RuntimeName,
}
if o.ServiceName != "" {
diff --git a/cmd/asz/refresh.go b/cmd/asz/refresh.go
index 6f521b8..ff89bf5 100644
--- a/cmd/asz/refresh.go
+++ b/cmd/asz/refresh.go
@@ -241,9 +241,10 @@ func newRefresher(srv *view.Server, zone *storage.Zone, ads []config.Adapter, mc
}
}
if r.col == nil && len(r.changes) == 0 && r.provider == nil && r.langsmith == nil {
- // Nothing local is enabled and nothing feeds the pipeline. A root
- // filled somewhere else, by the metrics receiver or copied from
- // another machine, is served as it is.
+ // Nothing local is enabled and nothing feeds the pipeline, so there
+ // is nothing to refresh, and the caller refuses to run. A root filled
+ // somewhere else, such as one copied from another machine, is served
+ // as it is by asz view.
fmt.Fprintln(os.Stderr, "source : no local adapter enabled; nothing is collected")
return nil, nil
}
diff --git a/docs/en/adapters/claude-code.md b/docs/en/adapters/claude-code.md
index f804211..49d8363 100644
--- a/docs/en/adapters/claude-code.md
+++ b/docs/en/adapters/claude-code.md
@@ -928,18 +928,13 @@ runtime's auxiliary calls, which never reach a transcript. asz does not send the
derivation over a root with history reaches back `metrics.lookback`, 72 hours unless set. See
[Metrics](../setup/export-otlp.md#metrics).
-### The runtime's exporter, received
-
-`claude-code-otlp` is an OpenTelemetry receiver the runtime's exporter can be pointed at, over
-gRPC or HTTP on one port. It accepts metrics, logs and traces, and keeps none of them. A runtime
-already set to export keeps working, and nothing it sends is counted beside what asz derives. It
-lands no transcript and reads none, so it adds no structure. The runtime's events carry the
-latency and cost a transcript never has. Landing them is not implemented.
-
-The receiver speaks OTLP because Claude Code has exactly one outward protocol. Its exporter setting
-accepts `console`, `otlp` or `prometheus` for metrics, and `console` or `otlp` for logs and traces.
-Any other value throws. There is no webhook and no custom sink. This was read from the Claude Code
-binary. The version was not recorded.
+### The runtime's exporter
+
+asz does not receive the runtime's exporter. Point the exporter at the receiver that reads it. The
+SkyWalking OAP reads its cost, active time, sessions, lines of code, commits, pull requests and
+edit decisions, and takes the tokens from asz, so a call is not counted twice.
+[Metrics of the agent runtime](https://skywalking.apache.org/docs/main/next/en/setup/backend/ai-agent-conversation/#metrics-of-the-agent-runtime)
+lists which source each of its metrics needs, and how to point the exporter at it.
## Local data hazards
diff --git a/docs/en/changes/changes.md b/docs/en/changes/changes.md
index dbcce47..8b31166 100644
--- a/docs/en/changes/changes.md
+++ b/docs/en/changes/changes.md
@@ -175,19 +175,20 @@
`agent.mcp.calls` and `agent.mcp.duration`, by server, tool, the source of the server's
configuration, outcome and query source. A server and a tool together name the target a call
reached, which a receiver can treat as an endpoint of the agent.
-- The `claude-code-otlp` receiver no longer keeps what Claude Code's exporter sends. It accepts
- metrics, logs and traces, over gRPC or over HTTP in any encoding, and keeps none of them. Its
- `metrics` key is gone. Every metric asz sends is derived from the landed files, so one root has one
- source for each count. The exporter's labels a transcript does not carry, such as the user and
- the organisation, and its other metrics, such as cost, are no longer sent. The spool holds only
- derived requests, and `spool.state` is no longer written.
+- The `claude-code-otlp` receiver is removed. Every metric asz sends is derived from the landed
+ files, so one root has one source for each count. The exporter's labels a transcript does not
+ carry, such as the user and the organisation, and its other metrics, such as cost, are no longer
+ sent. Point Claude Code's exporter at the receiver that reads it: the SkyWalking OAP reads its
+ cost, active time, sessions, lines of code, commits, pull requests and edit decisions, and takes
+ the tokens from asz. A configuration that still names the adapter loads, and an enabled entry is
+ skipped with a line that says so. The spool holds only derived requests, and `spool.state` is no
+ longer written.
## Development
- The `all-kinds` scenario holds a call to an MCP server, so the push to a real OpenTelemetry
Collector carries an `execution` file and the MCP metrics, and the check compares them with the
- root. The same run sends a real exporter's metrics to the `claude-code-otlp` receiver, and fails
- if any of them reaches the spool or the Collector.
+ root.
- The first benchmark: `BenchmarkLangChainRequests` measures cutting every request body of one
LangChain conversation, for context windows from 8K to 1M tokens, trimmed, summarised or neither.
`make test` and CI only compile it. Its comment gives the command that runs it, the reference
diff --git a/docs/en/formats/otlp.md b/docs/en/formats/otlp.md
index 5b7fc70..c735197 100644
--- a/docs/en/formats/otlp.md
+++ b/docs/en/formats/otlp.md
@@ -132,8 +132,8 @@ named for any agent rather than for one runtime:
Each is a monotonic delta sum, and every value is a double. A request's resource says
`service.name` `claude-code`, which asz normalises on the way out, and its instrumentation scope is
-asz's own. The `claude-code-otlp` adapter accepts what Claude Code's own exporter sends and keeps
-none of it, so one root has one source for each count.
+asz's own. asz does not receive Claude Code's own exporter, so one root has one source for each
+count.
### Token usage
@@ -154,8 +154,8 @@ says:
| a point's window | the exporter's export interval, wall clock | the minute the call's last fragment ended, see below |
| value | a double | a double |
-The right-hand column is held to a capture of what Claude Code 2.1.260 sent to asz's receiver
-from one short session, kept under `internal/metrics/testdata` with every identifying value
+The right-hand column is held to a capture of what the exporter of Claude Code 2.1.260 sent from
+one short session, kept under `internal/metrics/testdata` with every identifying value
replaced, by a test that fails when the exporter sends a label or a metric this table does not
account for. A new capture enters that directory only through the same test, which replaces the
identifying values:
diff --git a/docs/en/guides/command-line-internals.md b/docs/en/guides/command-line-internals.md
index dba1771..fcba489 100644
--- a/docs/en/guides/command-line-internals.md
+++ b/docs/en/guides/command-line-internals.md
@@ -37,7 +37,7 @@ leaving them for the next one.
In watch mode, the default, it repeats every `interval`; with `-once` or `mode: once` it makes one
pass and exits. Every enabled local adapter is read in the same pass, so one watching source never
-keeps another from running. When the `claude-code-otlp` adapter is enabled, its receiver listens
+keeps another from running. When the `langsmith-ingest` adapter is enabled, its receiver listens
beside the pipeline for as long as the process runs.
One line per pass, and a quiet pass prints nothing:
@@ -170,7 +170,7 @@ it needs no source files.
The pipeline and the page in one process: it lands, parses and sends on the collector interval,
and serves the result on `ADDR`, `127.0.0.1:8787` by default. This is what a person runs to watch
-their own conversations locally. It hosts the `claude-code-otlp` receiver too, when that adapter
+their own conversations locally. It hosts the `langsmith-ingest` receiver too, when that adapter
is enabled.
`/api/status` reports the mode, the source, the last and the next refresh and the counts of the
diff --git a/docs/en/setup/configuration.md b/docs/en/setup/configuration.md
index b43e628..94c1d9e 100644
--- a/docs/en/setup/configuration.md
+++ b/docs/en/setup/configuration.md
@@ -27,7 +27,6 @@ A list of the sources asz collects from.
| `claude-code-local` | yes | Claude Code's transcripts on this machine |
| `changes` | yes | what a tool call changed on disk, from the [Claude Code](claude-code-plugin.md) or [LangChain](langchain-plugin.md) plugin, and each call to an MCP server the Claude Code plugin saw; see [below](#the-changes-adapter) |
| `claude-code-provider` | yes | the request and response bodies Claude Code writes when asked; see [below](#the-provider-adapter) |
-| `claude-code-otlp` | no | nothing: it accepts what Claude Code's own OpenTelemetry exporter sends, and keeps none of it; see [below](#the-receiver-adapter) |
| `langsmith-ingest` | no | what the LangSmith tracing client sends, from a LangChain or LangGraph application; see [LangChain and LangGraph](langchain.md) |
### More than one agent
@@ -54,7 +53,7 @@ A list of the sources asz collects from.
| `source_root` | empty | The directory Claude Code keeps its projects in, one directory per project. Empty means `projects` under `CLAUDE_CONFIG_DIR`, else under `XDG_CONFIG_HOME/claude`, else under `~/.claude`. To collect from a copy, name the copy's `projects` directory, not the directory above it. |
| `include` | empty | [Session filters](#session-filters) a session must match. Empty means every session. |
| `exclude` | `/private/tmp/**` | [Session filters](#session-filters) that leave a session out. `exclude: []` clears the default. |
-| `listen` | none | `claude-code-otlp` only: the address to receive on, such as `127.0.0.1:4317`, gRPC and HTTP on one port. |
+| `listen` | `127.0.0.1:1985` | `langsmith-ingest` only: the address to receive on; see [LangChain and LangGraph](langchain.md). |
### Session filters
@@ -189,16 +188,3 @@ Two entries that read one directory are refused.
`claude-code-provider` collects the request and response bodies Claude Code sends to its model
provider. Claude Code writes them only when you turn it on, as
[Claude Code Provider Bodies](claude-code-provider-bodies.md) shows.
-
-## The receiver adapter
-
-```yaml
-adapters:
- - name: claude-code-otlp
- enabled: true
- listen: 127.0.0.1:4317
-```
-
-`claude-code-otlp` is for a Claude Code already set to send its own telemetry. It accepts metrics,
-logs and traces over gRPC or HTTP, and keeps none of them. The metrics asz sends come from the
-collected data. It runs while `asz collect` or `asz server` runs, but not with `-once`.
diff --git a/docs/en/setup/export-otlp.md b/docs/en/setup/export-otlp.md
index 447d560..890ccf7 100644
--- a/docs/en/setup/export-otlp.md
+++ b/docs/en/setup/export-otlp.md
@@ -38,6 +38,12 @@ metrics:
lookback: 72h # 0 or none sends all the history
```
+Cost, active time, sessions, lines of code, commits, pull requests and edit decisions come only
+from Claude Code's own OpenTelemetry exporter, and asz does not receive it. For the SkyWalking OAP,
+point the exporter at the OAP directly, as
+[Metrics of the agent runtime](https://skywalking.apache.org/docs/main/next/en/setup/backend/ai-agent-conversation/#metrics-of-the-agent-runtime)
+shows.
+
## Limit the rate
The first send carries the whole history. To spread it out, set a limit:
diff --git a/internal/adapters/claudecodeotlp/receiver.go b/internal/adapters/claudecodeotlp/receiver.go
deleted file mode 100644
index 30e3f37..0000000
--- a/internal/adapters/claudecodeotlp/receiver.go
+++ /dev/null
@@ -1,259 +0,0 @@
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements. See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership. The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License. You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied. See the License for the
-// specific language governing permissions and limitations
-// under the License.
-
-// Package claudecodeotlp is the claude-code-otlp adapter: an OpenTelemetry
-// receiver Claude Code's own exporter can be pointed at. It accepts metrics,
-// logs and traces, counts each request so the status says what the runtime
-// sends, and keeps none of them. A runtime configured with an exporter keeps
-// working, and nothing it sends is counted twice.
-//
-// asz does not land the exporter's metrics. Every metric asz sends is derived
-// from the landed files, under one name for every runtime, so one root has
-// one source for each count.
-package claudecodeotlp
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "net"
- "net/http"
- "strings"
- "sync"
- "sync/atomic"
- "time"
-
- collogspb "go.opentelemetry.io/proto/otlp/collector/logs/v1"
- collmetricspb "go.opentelemetry.io/proto/otlp/collector/metrics/v1"
- colltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1"
- "google.golang.org/grpc"
-)
-
-const (
- // Name is the adapter's name in the configuration.
- Name = "claude-code-otlp"
- // Version is the adapter's contract version.
- Version = "0.1.0"
- // MaxRequest bounds one request, well above what the runtime's exporter
- // sends in a minute.
- MaxRequest = 64 << 20
-)
-
-// Stats counts the requests accepted and dropped since the receiver started.
-type Stats struct {
- Metrics int64
- Logs int64
- Traces int64
-}
-
-// Receiver listens for the runtime's exporter on one address, over gRPC
-// and over HTTP with protobuf, the two protocols the runtime offers.
-type Receiver struct {
- // Listen is the address, such as 127.0.0.1:4317. Both protocols share
- // it: gRPC and HTTP/1.1 are told apart on the first bytes.
- Listen string
-
- metrics atomic.Int64
- logs atomic.Int64
- traces atomic.Int64
-
- mu sync.Mutex
- ln net.Listener
- rpc *grpc.Server
- web *http.Server
- done chan struct{}
-}
-
-// Stats reports the counts so far.
-func (r *Receiver) Stats() Stats {
- return Stats{Metrics: r.metrics.Load(), Logs: r.logs.Load(), Traces: r.traces.Load()}
-}
-
-// Addr is the address the receiver listens on, once started.
-func (r *Receiver) Addr() string {
- r.mu.Lock()
- defer r.mu.Unlock()
- if r.ln == nil {
- return ""
- }
- return r.ln.Addr().String()
-}
-
-// Start opens the listener and serves until Stop. The one port serves
-// both protocols: a connection that opens with an HTTP/2 preface is gRPC,
-// anything else is HTTP/1.1.
-func (r *Receiver) Start() error {
- if r.Listen == "" {
- return errors.New("claude-code-otlp: no listen address")
- }
- ln, err := net.Listen("tcp", r.Listen)
- if err != nil {
- return fmt.Errorf("claude-code-otlp: %w", err)
- }
- r.rpc = grpc.NewServer(grpc.MaxRecvMsgSize(MaxRequest))
- collmetricspb.RegisterMetricsServiceServer(r.rpc, &metricsServer{r: r})
- collogspb.RegisterLogsServiceServer(r.rpc, &logsServer{r: r})
- colltracepb.RegisterTraceServiceServer(r.rpc, &traceServer{r: r})
- mux := http.NewServeMux()
- mux.HandleFunc("/v1/metrics", r.serveDropped(&r.metrics))
- mux.HandleFunc("/v1/logs", r.serveDropped(&r.logs))
- mux.HandleFunc("/v1/traces", r.serveDropped(&r.traces))
- r.web = &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
- r.mu.Lock()
- r.ln = ln
- r.done = make(chan struct{})
- r.mu.Unlock()
- go r.serve(ln)
- return nil
-}
-
-// serve splits the one listener by protocol. gRPC needs HTTP/2, which
-// starts with a fixed preface; an HTTP/1.1 request starts with a method.
-func (r *Receiver) serve(ln net.Listener) {
- defer close(r.done)
- grpcLn := &pipeListener{addr: ln.Addr(), conns: make(chan net.Conn), closed: make(chan struct{})}
- webLn := &pipeListener{addr: ln.Addr(), conns: make(chan net.Conn), closed: make(chan struct{})}
- go func() { _ = r.rpc.Serve(grpcLn) }()
- go func() { _ = r.web.Serve(webLn) }()
- for {
- c, err := ln.Accept()
- if err != nil {
- close(grpcLn.closed)
- close(webLn.closed)
- return
- }
- go func(c net.Conn) {
- pc := &peekConn{Conn: c}
- if pc.isHTTP2() {
- grpcLn.conns <- pc
- } else {
- webLn.conns <- pc
- }
- }(c)
- }
-}
-
-// Stop closes the listener and waits for the servers to finish.
-func (r *Receiver) Stop() {
- r.mu.Lock()
- ln, done := r.ln, r.done
- r.mu.Unlock()
- if ln == nil {
- return
- }
- _ = ln.Close()
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- _ = r.web.Shutdown(ctx)
- r.rpc.Stop()
- <-done
-}
-
-// serveDropped accepts a request and keeps nothing of it, counting it so the
-// status can say what the runtime is sending. The answer is an empty export
-// response in the request's own encoding, as OTLP asks: no bytes in
-// protobuf, and {} in JSON.
-func (r *Receiver) serveDropped(counter *atomic.Int64) http.HandlerFunc {
- return func(w http.ResponseWriter, req *http.Request) {
- _, _ = io.Copy(io.Discard, io.LimitReader(req.Body, MaxRequest))
- counter.Add(1)
- if strings.HasPrefix(req.Header.Get("Content-Type"), "application/json") {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte("{}"))
- return
- }
- w.Header().Set("Content-Type", "application/x-protobuf")
- w.WriteHeader(http.StatusOK)
- }
-}
-
-type metricsServer struct {
- collmetricspb.UnimplementedMetricsServiceServer
- r *Receiver
-}
-
-func (s *metricsServer) Export(context.Context, *collmetricspb.ExportMetricsServiceRequest) (*collmetricspb.ExportMetricsServiceResponse, error) {
- s.r.metrics.Add(1)
- return &collmetricspb.ExportMetricsServiceResponse{}, nil
-}
-
-type logsServer struct {
- collogspb.UnimplementedLogsServiceServer
- r *Receiver
-}
-
-func (s *logsServer) Export(context.Context, *collogspb.ExportLogsServiceRequest) (*collogspb.ExportLogsServiceResponse, error) {
- s.r.logs.Add(1)
- return &collogspb.ExportLogsServiceResponse{}, nil
-}
-
-type traceServer struct {
- colltracepb.UnimplementedTraceServiceServer
- r *Receiver
-}
-
-func (s *traceServer) Export(context.Context, *colltracepb.ExportTraceServiceRequest) (*colltracepb.ExportTraceServiceResponse, error) {
- s.r.traces.Add(1)
- return &colltracepb.ExportTraceServiceResponse{}, nil
-}
-
-// pipeListener hands the connections one protocol owns to its server.
-type pipeListener struct {
- addr net.Addr
- conns chan net.Conn
- closed chan struct{}
-}
-
-func (l *pipeListener) Accept() (net.Conn, error) {
- select {
- case c := <-l.conns:
- return c, nil
- case <-l.closed:
- return nil, net.ErrClosed
- }
-}
-func (l *pipeListener) Close() error { return nil }
-func (l *pipeListener) Addr() net.Addr { return l.addr }
-
-// peekConn reads the first bytes of a connection to tell the protocol and
-// gives them back to whoever reads next.
-type peekConn struct {
- net.Conn
- head []byte
-}
-
-const h2Preface = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
-
-func (c *peekConn) isHTTP2() bool {
- buf := make([]byte, len(h2Preface))
- _ = c.SetReadDeadline(time.Now().Add(5 * time.Second))
- n, _ := io.ReadFull(c.Conn, buf)
- _ = c.SetReadDeadline(time.Time{})
- c.head = buf[:n]
- return string(c.head) == h2Preface
-}
-
-func (c *peekConn) Read(p []byte) (int, error) {
- if len(c.head) > 0 {
- n := copy(p, c.head)
- c.head = c.head[n:]
- return n, nil
- }
- return c.Conn.Read(p)
-}
diff --git a/internal/adapters/claudecodeotlp/receiver_test.go b/internal/adapters/claudecodeotlp/receiver_test.go
deleted file mode 100644
index 9e135ec..0000000
--- a/internal/adapters/claudecodeotlp/receiver_test.go
+++ /dev/null
@@ -1,90 +0,0 @@
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements. See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership. The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License. You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied. See the License for the
-// specific language governing permissions and limitations
-// under the License.
-
-package claudecodeotlp_test
-
-import (
- "bytes"
- "io"
- "net/http"
- "testing"
- "time"
-
- collogspb "go.opentelemetry.io/proto/otlp/collector/logs/v1"
- collmetricspb "go.opentelemetry.io/proto/otlp/collector/metrics/v1"
- commonpb "go.opentelemetry.io/proto/otlp/common/v1"
- metricspb "go.opentelemetry.io/proto/otlp/metrics/v1"
- resourcepb "go.opentelemetry.io/proto/otlp/resource/v1"
-
- "github.com/apache/skywalking-ai-sessionizer/internal/adapters/claudecodeotlp"
- "github.com/apache/skywalking-ai-sessionizer/internal/export/otlp"
-)
-
-func request() *collmetricspb.ExportMetricsServiceRequest {
- return &collmetricspb.ExportMetricsServiceRequest{ResourceMetrics: []*metricspb.ResourceMetrics{{
- Resource: &resourcepb.Resource{Attributes: []*commonpb.KeyValue{
- {Key: "service.name", Value: &commonpb.AnyValue{Value: &commonpb.AnyValue_StringValue{StringValue: "claude-code"}}},
- }},
- ScopeMetrics: []*metricspb.ScopeMetrics{{Metrics: []*metricspb.Metric{{Name: "claude_code.token.usage", Unit: "tokens",
- Data: &metricspb.Metric_Sum{Sum: &metricspb.Sum{IsMonotonic: true, DataPoints: []*metricspb.NumberDataPoint{{TimeUnixNano: 1, Value: &metricspb.NumberDataPoint_AsInt{AsInt: 42}}}}}}}}},
- }}}
-}
-
-// The receiver takes what the runtime's exporter sends over either protocol
-// on one port, answers each request as accepted, and counts it. It keeps
-// nothing: asz derives every metric it sends from the landed files, so a
-// runtime that also exports must not reach the spool.
-func TestReceiverAcceptsAndDropsBothProtocols(t *testing.T) {
- rcv := &claudecodeotlp.Receiver{Listen: "127.0.0.1:0"}
- if err := rcv.Start(); err != nil {
- t.Fatal(err)
- }
- defer rcv.Stop()
- addr := rcv.Addr()
-
- for _, o := range []otlp.Options{
- {Protocol: otlp.ProtocolGRPC, Endpoint: addr, Timeout: 10 * time.Second},
- {Protocol: otlp.ProtocolHTTP, Endpoint: "http://" + addr, Timeout: 10 * time.Second},
- } {
- c, err := otlp.NewClient(o)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := c.ExportMetrics(request()); err != nil {
- t.Fatalf("%s: a metrics request must be accepted and dropped: %v", o.Protocol, err)
- }
- if _, err := c.Export(&collogspb.ExportLogsServiceRequest{}); err != nil {
- t.Fatalf("%s: a logs request must be accepted and dropped: %v", o.Protocol, err)
- }
- _ = c.Close()
- }
- // A JSON body is accepted too: nothing is decoded, so its encoding does
- // not matter, and a runtime set to http/json keeps working. The answer is
- // in JSON, as OTLP asks.
- resp, err := http.Post("http://"+addr+"/v1/traces", "application/json", bytes.NewReader([]byte("{}")))
- if err != nil {
- t.Fatal(err)
- }
- body, _ := io.ReadAll(resp.Body)
- resp.Body.Close()
- if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Type") != "application/json" || string(body) != "{}" {
- t.Fatalf("a JSON request answered %d %q %q", resp.StatusCode, resp.Header.Get("Content-Type"), body)
- }
- if st := rcv.Stats(); st.Metrics != 2 || st.Logs != 2 || st.Traces != 1 {
- t.Fatalf("stats %+v", st)
- }
-}
diff --git a/internal/config/config.go b/internal/config/config.go
index 6fa6f6a..75c4a0c 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -209,9 +209,8 @@ type Adapter struct {
Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"`
- // Listen, on claude-code-otlp, is the address the receiver listens on
- // for the runtime's exporter, such as 127.0.0.1:4317, over gRPC and
- // HTTP on the one port. It keeps nothing it receives.
+ // Listen, on langsmith-ingest, is the address the receiver listens on,
+ // the one LANGSMITH_ENDPOINT is pointed at, such as 127.0.0.1:1985.
Listen string `yaml:"listen"`
// Token, on langsmith-ingest, is required in the x-api-key header.
// Empty accepts any key, which is what a local collector wants: the
@@ -253,6 +252,12 @@ func (a *Adapter) UnmarshalYAML(node *yaml.Node) error {
}
type plain Adapter
value := plain{Name: name.Name, Enabled: true}
+ if name.Name == RemovedClaudeCodeOTLP {
+ // It was off unless turned on, and an entry written for it still
+ // means that. Without this, an entry with no enabled key would turn
+ // on, and print a skip line where it used to print nothing.
+ value.Enabled = false
+ }
for _, def := range Default().Adapters {
if sameAdapter(def.Name, name.Name) {
value = plain(def)
@@ -295,9 +300,13 @@ const (
// AdapterClaudeCodeLocal reads Claude Code's local files. Pull posture.
AdapterClaudeCodeLocal = "claude-code-local"
- // AdapterClaudeCodeOTLP receives what Claude Code's own OpenTelemetry
- // exporter sends. Push posture, the runtime's side.
- AdapterClaudeCodeOTLP = "claude-code-otlp"
+ // RemovedClaudeCodeOTLP names the receiver for Claude Code's own
+ // OpenTelemetry exporter that 0.5.0 and earlier had. It is gone because
+ // every metric asz sends is derived from the landed files. The exporter
+ // is pointed at the receiver that reads it, such as the SkyWalking OAP.
+ // A configuration written then still names it. Such an entry lands
+ // nothing. When it is enabled, it is skipped with a line that says so.
+ RemovedClaudeCodeOTLP = "claude-code-otlp"
// AdapterClaudeCodeChanges reads the workspace change records the asz
// Claude Code plugin writes beside Claude Code's own files. Pull
// posture, like the local adapter, and the plugin needs nothing from
@@ -333,12 +342,6 @@ func Default() *Config {
Interval: DefaultInterval,
MaxDeltaBytes: 2 << 20,
},
- }, {
- // The runtime's own exporter, received. Off until pointed at:
- // the file names the address the runtime would be given.
- Name: AdapterClaudeCodeOTLP,
- Enabled: false,
- Listen: "127.0.0.1:4317",
}, {
// The LangSmith tracing client, received. Off until pointed
// at: the file names the address an application would be
@@ -407,13 +410,6 @@ func isChanges(name string) bool {
return name == AdapterChanges || name == AdapterClaudeCodeChanges
}
-// isReceiver reports whether an adapter is a server rather than a reader. A
-// receiver polls nothing and lands no transcript of its own, so the collector
-// settings do not apply to it.
-func isReceiver(name string) bool {
- return name == AdapterClaudeCodeOTLP
-}
-
// Load reads a YAML config, applying defaults for anything unset. An empty
// path returns Default.
func Load(path string) (*Config, error) {
@@ -440,11 +436,7 @@ func Load(path string) (*Config, error) {
if len(loaded.Adapters) > 0 {
cfg.Adapters = loaded.Adapters
for i := range cfg.Adapters {
- // A receiver is a server: it polls nothing and lands no
- // transcript, so the collector settings do not apply to it.
- if !isReceiver(cfg.Adapters[i].Name) {
- cfg.Adapters[i].Collector.applyDefaults()
- }
+ cfg.Adapters[i].Collector.applyDefaults()
}
}
if loaded.Parse.MaxRoundBytes > 0 {
@@ -599,11 +591,6 @@ func (c *Config) Validate() error {
}
seen[a.Name] = true
}
- if a.Name == AdapterClaudeCodeOTLP && a.Enabled {
- if a.Listen == "" {
- return fmt.Errorf("config: adapter %q needs listen, the address the runtime's exporter is pointed at, such as 127.0.0.1:4317", a.Name)
- }
- }
if a.Name != AdapterLangSmithIngest && a.ProviderBodies {
return fmt.Errorf("config: adapter %q does not take provider_bodies; only %q does, and %q lands Claude Code's bodies on its own", a.Name, AdapterLangSmithIngest, AdapterClaudeCodeProvider)
}
@@ -614,11 +601,10 @@ func (c *Config) Validate() error {
if a.SourceRoot != "" || len(a.Include) > 0 || len(a.Exclude) > 0 {
return fmt.Errorf("config: adapter %q is a receiver: it takes listen, token, thread_keys, scope, provider_bodies and collector, not source_root, include or exclude", a.Name)
}
- // It takes a period and a byte budget, unlike the metrics
- // receiver: what it accepts waits in an inbox until a pass
- // converts it, and one request can be larger than a landed file
- // should be. A single turn with a 20 KB command and a 32 KB
- // result measured 3 MB.
+ // It takes a period and a byte budget: what it accepts waits in
+ // an inbox until a pass converts it, and one request can be
+ // larger than a landed file should be. A single turn with a
+ // 20 KB command and a 32 KB result measured 3 MB.
if len(a.Scope) == 0 {
return fmt.Errorf("config: adapter %q needs scope, the dimensions that own a conversation; the default is [project, thread]", a.Name)
}
@@ -627,12 +613,6 @@ func (c *Config) Validate() error {
}
continue
}
- if a.Name == AdapterClaudeCodeOTLP {
- if a.Collector != (Collector{}) || a.SourceRoot != "" || len(a.Include) > 0 || len(a.Exclude) > 0 {
- return fmt.Errorf("config: adapter %q is a receiver: it takes listen, not collector, source_root, include or exclude", a.Name)
- }
- continue
- }
if isChanges(a.Name) && a.Listen != "" {
return fmt.Errorf("config: adapter %q reads the plugin's records only: it takes source_root, include, exclude and collector, not listen", a.Name)
}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 5d0393a..a9b7ba4 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -86,46 +86,69 @@ func TestRepoConfigSpellsOutEveryValue(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if len(got.Adapters) != 5 {
- t.Fatalf("adapters: got %d, want the local adapter, the two receivers, the changes adapter and the provider adapter", len(got.Adapters))
+ if len(got.Adapters) != 4 {
+ t.Fatalf("adapters: got %d, want the local adapter, the receiver, the changes adapter and the provider adapter", len(got.Adapters))
}
for _, a := range got.Adapters {
- if isReceiver(a.Name) {
- // A receiver is a server and has no collector to spell out.
- if a.Collector != (Collector{}) {
- t.Fatalf("%s: a receiver carries collector settings: %+v", a.Name, a.Collector)
- }
- continue
- }
if a.Collector.Mode == "" || a.Collector.Interval == 0 || a.Collector.MaxDeltaBytes == 0 {
t.Fatalf("%s: collector values not spelled out: %+v", a.Name, a.Collector)
}
}
}
-// The receiver adapter needs an address, and it is a server with no
-// collector.
+// The receiver adapter needs an address. Without one it could not start,
+// and the file is refused when it loads, with an error that names the
+// setting, rather than when the listener starts.
func TestReceiverNeedsAnAddress(t *testing.T) {
cfg := Default()
- if cfg.Adapters[1].Name != AdapterClaudeCodeOTLP {
- t.Fatalf("the defaults list %s second, want the receiver", cfg.Adapters[1].Name)
+ i := slices.IndexFunc(cfg.Adapters, func(a Adapter) bool { return a.Name == AdapterLangSmithIngest })
+ if i < 0 {
+ t.Fatal("the defaults list no receiver")
}
- cfg.Adapters[1].Enabled = true
- cfg.Adapters[1].Listen = ""
+ cfg.Adapters[i].Enabled = true
+ cfg.Adapters[i].Listen = ""
if err := cfg.Validate(); err == nil {
t.Fatal("a receiver without listen was accepted")
}
- cfg.Adapters[1].Listen = "127.0.0.1:4317"
- cfg.Adapters[1].Collector.Mode = ModeWatch
- if err := cfg.Validate(); err == nil {
- t.Fatal("a receiver with collector settings was accepted; it is a server")
- }
- cfg.Adapters[1].Collector = Collector{}
+ cfg.Adapters[i].Listen = "127.0.0.1:1985"
if err := cfg.Validate(); err != nil {
t.Fatalf("a receiver with an address must be accepted: %v", err)
}
}
+// A configuration written for 0.5.0 names the receiver for Claude Code's
+// exporter, with its address. asz no longer has it, and the file must still
+// load, so a collector that was running goes on running.
+func TestARemovedReceiverStillLoads(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "asz.yaml")
+ body := "adapters:\n" +
+ " - name: claude-code-local\n enabled: true\n" +
+ " - name: claude-code-otlp\n enabled: true\n listen: 127.0.0.1:4317\n metrics: true\n"
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := Load(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := cfg.Validate(); err != nil {
+ t.Fatalf("a 0.5.0 configuration is refused: %v", err)
+ }
+ if cfg.Adapters[1].Name != RemovedClaudeCodeOTLP {
+ t.Fatalf("the second adapter is %q", cfg.Adapters[1].Name)
+ }
+
+ // The receiver was off unless turned on. An entry with no enabled key
+ // stays off, as it was.
+ var bare Adapter
+ if err := yaml.Unmarshal([]byte("name: claude-code-otlp\nlisten: 127.0.0.1:4317\n"), &bare); err != nil {
+ t.Fatal(err)
+ }
+ if bare.Enabled {
+ t.Fatal("an entry with no enabled key is on; in 0.5.0 it was off")
+ }
+}
+
// Metrics are one section for the root, read as written, false included.
// A section left out derives every metric with a look-back of three days.
func TestMetricsAreOneSectionForTheRoot(t *testing.T) {
diff --git a/internal/metrics/capture_test.go b/internal/metrics/capture_test.go
index 3a5a967..18ec23c 100644
--- a/internal/metrics/capture_test.go
+++ b/internal/metrics/capture_test.go
@@ -35,11 +35,12 @@ import (
//
// go test ./internal/metrics -run TestCaptures -capture "$PWD/FILE.pb" -capture-name claude-code-VERSION-metrics
//
-// FILE.pb is one spooled ExportMetricsServiceRequest. The test writes
+// FILE.pb is one ExportMetricsServiceRequest the exporter sent, as protobuf
+// bytes, saved by whatever OTLP receiver it was pointed at. The test writes
// testdata/NAME.pb with the identifying values replaced, and testdata/NAME.json,
// the same request in the protocol's JSON, for a person to read.
var (
- captureFile = flag.String("capture", "", "a spooled OTLP metrics request to redact into testdata")
+ captureFile = flag.String("capture", "", "an OTLP metrics request, as protobuf bytes, to redact into testdata")
captureName = flag.String("capture-name", "", "the name of the capture in testdata, with no extension")
)
diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go
index 1aca313..96c5e06 100644
--- a/internal/metrics/metrics.go
+++ b/internal/metrics/metrics.go
@@ -17,8 +17,7 @@
// Package metrics derives asz's metrics from landed Session Data: the tokens
// of each model call, and the calls to MCP servers the plugin recorded.
-// Every metric asz sends comes from here; nothing the runtime's own exporter
-// sends is kept.
+// Every metric asz sends comes from here.
//
// The token metric counts what Claude Code's exporter counts, under asz's
// name. The unit, the delta temporality and the token types are the
diff --git a/internal/metrics/parity_test.go b/internal/metrics/parity_test.go
index 5883b0c..bef6da8 100644
--- a/internal/metrics/parity_test.go
+++ b/internal/metrics/parity_test.go
@@ -33,7 +33,7 @@ import (
"github.com/apache/skywalking-ai-sessionizer/internal/storage"
)
-// The parity fixture: what Claude Code 2.1.260 sent to asz's receiver from
+// The parity fixture: what the exporter of Claude Code 2.1.260 sent from
// one short non-interactive Opus session on 2026-09-07, over gRPC, with
// the values of every attribute naming a person, an account, an
// organisation or a session replaced. testdata/claude-code-2.1.260-metrics.json
diff --git a/tools/test/collector-check/main.go b/tools/test/collector-check/main.go
index 1a10f44..0dbeb11 100644
--- a/tools/test/collector-check/main.go
+++ b/tools/test/collector-check/main.go
@@ -168,11 +168,6 @@ func run(root, logs string) error {
if dp.AsInt != "" {
n, _ = strconv.ParseInt(dp.AsInt, 10, 64)
}
- // The receiver accepts the runtime's exporter and
- // keeps nothing, so none of its points is sent on.
- if a["sender"] == "telemetrygen" {
- return fmt.Errorf("a point the exporter sent to asz's receiver reached the Collector: %v", a)
- }
switch m.Name {
case "agent.token.usage":
tokens[a["query_source"]+"/"+a["type"]] += n
diff --git a/tools/test/e2e-collector.sh b/tools/test/e2e-collector.sh
index 3c36ead..2c04a49 100755
--- a/tools/test/e2e-collector.sh
+++ b/tools/test/e2e-collector.sh
@@ -22,15 +22,8 @@
set -eu
cd "$(dirname "$0")/../.."
IMAGE="${OTELCOL_IMAGE:-otel/opentelemetry-collector-contrib:0.158.0}"
-# The Collector project's own OTLP exporter, telemetrygen: a real external
-# exporter to point at asz's receiver, in place of Claude Code's.
-GEN_IMAGE="${TELEMETRYGEN_IMAGE:-ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:v0.158.0}"
GRPC_PORT="${OTELCOL_GRPC_PORT:-14317}"
HTTP_PORT="${OTELCOL_HTTP_PORT:-14318}"
-# Where asz's receiver listens for the exporter, reachable from a container
-# as host.docker.internal. Both address families, since the name may resolve
-# to either.
-RECEIVE_PORT="${ASZ_RECEIVE_PORT:-14417}"
WORK="$(mktemp -d)"
trap 'docker rm -f asz-e2e-otelcol >/dev/null 2>&1 || true; rm -rf "$WORK"' EXIT
@@ -100,53 +93,6 @@ YAML
sleep 2
done
- # The runtime's own exporter, pointed at asz's receiver. A second root runs
- # the receiver adapter alone, and a real external exporter sends it a
- # token metric over gRPC and over HTTP. The receiver accepts both and keeps
- # nothing: every metric asz sends is derived from the landed files, so the
- # second root's spool stays empty and its push sends nothing. The check
- # then fails on any point marked with the exporter's sender attribute.
- received="$WORK/$protocol-received"
- mkdir -p "$received"
- cat > "$received/asz.yaml" <<YAML
-storage:
- root: $received
-adapters:
- - name: claude-code-local
- enabled: false
- - name: claude-code-otlp
- enabled: true
- listen: ":$RECEIVE_PORT"
-export:
- otlp:
- protocol: $protocol
- endpoint: $endpoint
-YAML
- ./bin/asz collect -config "$received/asz.yaml" > "$received/collect.log" 2>&1 &
- receiver_pid=$!
- for i in $(seq 1 30); do
- if curl -s -o /dev/null "http://127.0.0.1:$RECEIVE_PORT/v1/metrics"; then break; fi
- sleep 1
- done
- echo "== the exporter sends to asz's receiver on $RECEIVE_PORT, gRPC then HTTP"
- for transport in "" "--otlp-http"; do
- # shellcheck disable=SC2086
- docker run --rm --add-host=host.docker.internal:host-gateway "$GEN_IMAGE" metrics \
- --otlp-endpoint "host.docker.internal:$RECEIVE_PORT" --otlp-insecure $transport \
- --metrics 3 --rate 0 --metric-type Sum --aggregation-temporality delta \
- --otlp-metric-name claude_code.token.usage --service claude-code \
- --telemetry-attributes 'type="input"' --telemetry-attributes 'query_source="main"' \
- --telemetry-attributes 'session.id="telemetrygen"' --telemetry-attributes 'sender="telemetrygen"' >/dev/null 2>&1 \
- || { echo "the exporter could not send to the receiver"; cat "$received/collect.log"; exit 1; }
- done
- kill "$receiver_pid" >/dev/null 2>&1 || true
- wait "$receiver_pid" 2>/dev/null || true
- head -3 "$received/collect.log"
- if ls "$received/_metrics" 2>/dev/null | grep -q '\.pb$'; then
- echo "the receiver put the exporter's metrics in the spool; it must accept and drop them"
- exit 1
- fi
- ./bin/asz push -once -config "$received/asz.yaml"
# The file exporter flushes on its own schedule; give it a moment.
for i in $(seq 1 20); do
if [ -s "$WORK/otelcol/logs.json" ]; then break; fi