Skip to content
GitHub

Export loop telemetry

Resolve this repository's telemetry settings and send its work-loop events to a Collector.

Use the catalogue resolver to build the exact jsonl-otlp-export arguments for the repository you want to observe. A useful request to give your agent is:

Resolve the loop telemetry invocation for this repository.

Nothing, until you decide otherwise — nothing is sent until you configure an endpoint. This page describes a capability you install on purpose, not one that is already running.

When you do configure an endpoint, here is exactly what goes and where.

  • What is read: .loop-run/events.jsonl at your repository root. Your work-loop writes one line to it per phase change. The file is local and gitignored.

  • The log is best-effort, so read it as a sample and not as a ledger. A phase change that cannot be written down still happens: the work-loop prints a warning to its error output and carries on rather than failing the transition. Once that warning has scrolled past, the only trace is a line that is not there. Do not raise an alert on a missing record, and do not read a gap as proof that a phase was skipped.

  • What is sent: those lines, as OTLP logs, to the Collector address you configured. One log record per transition.

  • What each record carries — the whole list, not a summary. Fourteen fields:

    FieldIn plain words
    specthe path of the spec being worked on, such as docs/specs/my-feature. This is a name you chose, and it leaves your machine.
    run_id, seqwhich run this is, and where in it — together they let a receiver drop duplicates
    from, to, eventthe phase it left, the phase it entered, and what moved it
    at, phase_started_at, phase_swhen the transition happened, when the phase began, how many seconds it lasted
    resultwhat a gate decided — success, failure, or nothing at all
    awaiting_inputwhether the run has stopped to wait for a person
    waivedwhether someone passed an override flag on this transition
    budgetshow many retries the run has used, and its limits
    schemawhich version of this record shape it is

    No message body is sent — the record has a set of named fields and nothing else. Three words are used above and below, so here is what each means.

  • A Collector is the OpenTelemetry receiver you run. It is the address you configure, and nothing here talks to a vendor directly.

  • The profile is a small file this pack ships that says where each field goes. You do not write it.

  • Its allowlist is the fixed list of fields the profile permits to be sent.

  • What the allowlist governs: the fields sent as ordinary labels on the record. A field the engine adds later does not start flowing on its own — the profile has to name it first.

  • Four more fields are sent even though the allowlist does not name them, because the profile routes each one to a destination of its own:

    • at becomes the record’s timestamp;
    • result becomes its severity, and is left off entirely when it has no mapping — which is the case for the five routing transitions that decide nothing;
    • run_id and seq are sent as attributes, alongside the allowlisted ones, because together they are the record’s identity and a consumer deduplicates on them.
  • One thing rides outside the records. Each request carries a resource block naming the service the logs belong to — service.name. Its value is whatever you set for service_name, or, if you set nothing, the profile filename with its extension removed — work-loop for the profile this pack ships. It is the one string in the payload you choose yourself, so do not put anything in it you would not send.

  • What is never sent: any field that is neither routed above, named by the allowlist, nor the service.name just described.

  • What a pack cannot see at all: your prompts, the model’s replies, and token counts. A pack never sees the model call, so it has nothing to send.

  • Where it goes: the Collector you run. Point it at your own infrastructure, never at a vendor endpoint directly.

  • What this page cannot promise. It describes what the sender puts on the wire. It says nothing about what your Collector then does — how long it keeps the records, who can read them, or where it forwards them. Those are its settings, not this one’s, and they are worth checking before you turn this on.

The sender is a separate distribution, jsonl-otlp-exporter. If you never install it, nothing can send, whatever any configuration file says.

Two steps, and the first is the consent. Until you write an endpoint, nothing can be sent no matter what else is installed.

1. Name your Collector. Create agentbundle-layout.toml at your repository root:

[telemetry]
endpoint = "http://127.0.0.1:4318"

That address is your own Collector. Use the IPv4 loopback form rather than localhost if you are running one in a container locally — localhost can resolve to IPv6 first and be refused.

You may also set service_name, which becomes the name your backend files these logs under. If you leave it out it defaults to work-loop.

This file is a team decision: a value here wins over the same setting in your personal ~/.agentbundle/agentbundle-layout.toml, because sending data off the machine is not a personal preference. A setting the repository file leaves out falls through to yours.

2. Install the sender. jsonl-otlp-exporter is a separate distribution. If you never install it, nothing can send, whatever this file says.

There is no second switch for record contents, because there are none: the records carry the named fields listed above and no message body.

Run this from the repository root after installing jsonl-otlp-exporter. It writes nothing. Every path below is built from your repository root and your home directory, so this needs nothing installed but Python itself — which is what lets a hook run it.

import subprocess
from pathlib import Path
repo_root = Path.cwd()
user_root = Path.home() / ".agentbundle"
subprocess.run([
"jsonl-otlp-export",
"--input", str(repo_root / ".loop-run" / "events.jsonl"),
"--root", str(repo_root),
# Both layout files, not one. The sender reads `[telemetry]` from each and
# resolves every setting on its own, so it needs both paths rather than a
# decision about which file wins.
"--config", str(repo_root / "agentbundle-layout.toml"),
"--user-config", str(user_root / "agentbundle-layout.toml"),
"--profile", str(
repo_root / "packs" / "core" / ".apm" / "skills" / "work-loop"
/ "profiles" / "work-loop.toml"
),
], check=True)

Either layout file may be missing. A path that does not exist contributes nothing and is not an error, so this same call works whether you configured telemetry for the repository, for yourself, or only through an environment variable.

That run sends what is in the file and exits. It measures the file once, when it opens it, and stops at that many bytes — so a transition your work-loop appends while the run is in flight, or any time after it, is not sent. This is deliberate: against a work-loop appending faster than the sender drains, a run with no such bound would never reach the end and never exit.

So a single run is a snapshot, not a feed. To keep sending as the loop runs, add --follow, which keeps reading appended lines instead of stopping at the size it first saw, and bound it with --for <seconds> so it ends on its own. A successful one-shot run means every line present when it started was sent. It does not mean export is running.

That is the form to use. If you would rather read the command than run it, print it — but quote it, because these arguments carry paths from your own filesystem. This block stands on its own:

import shlex
from pathlib import Path
repo_root = Path.cwd()
user_root = Path.home() / ".agentbundle"
print("jsonl-otlp-export", shlex.join([
"--input", str(repo_root / ".loop-run" / "events.jsonl"),
"--root", str(repo_root),
"--config", str(repo_root / "agentbundle-layout.toml"),
"--user-config", str(user_root / "agentbundle-layout.toml"),
"--profile", str(
repo_root / "packs" / "core" / ".apm" / "skills" / "work-loop"
/ "profiles" / "work-loop.toml"
),
]))

which produces:

jsonl-otlp-export --input <repo>/.loop-run/events.jsonl --root <repo> --config <repo>/agentbundle-layout.toml --user-config <home>/.agentbundle/agentbundle-layout.toml --profile <repo>/packs/core/.apm/skills/work-loop/profiles/work-loop.toml

shlex.join is POSIX shell quoting. That printed line is safe to paste into sh, bash or zsh. It is not safe for Windows cmd.exe, where single quotes do not protect separators and a value such as x&whoami&x would still run. On Windows, and in any script, use the subprocess form above.

The sender resolves each [telemetry] setting across both files on its own: a setting your repository declares wins, and only one it leaves out falls through to yours. That is why both paths are passed rather than whichever file happens to hold the endpoint — a repository service_name alongside a personal endpoint is a normal configuration, and no single file expresses it.

[telemetry] accepts exactly endpoint and service_name. Any other key is refused, naming which file it came from, rather than ignored — so a setting that would quietly do nothing tells you instead. The files are read on every run, so you hear about a bad key even when an environment variable is supplying the endpoint. so a setting that would do nothing tells you instead of failing quietly.