# OTEL Foundation + Server Tracing Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Deliver the first shippable increment of OTEL observability for Culture — the `culture/telemetry/` package, OTEL dependencies, the `culture.dev/traceparent` IRCv3 tag, its protocol-extension doc, and real tracing wired into the server's event hub, client command dispatch, and PRIVMSG path. End state: an operator running `otelcol-contrib` sees three-span traces for every PRIVMSG.

**Architecture:** New `culture/telemetry/` package with no-op providers when disabled, OTLP/gRPC exporter when enabled. W3C trace context carried via a new IRCv3 tag (`culture.dev/traceparent`) with length-capped extract/inject helpers. Instrumentation attached at four choke points: `IRCd.__init__` (SDK bootstrap), `IRCd.emit_event` (every event), `Client._dispatch` (every inbound command), `Client.send`/`send_raw` (traceparent injection on outbound).

**Tech Stack:** Python 3.12, asyncio, `opentelemetry-api`/`opentelemetry-sdk`/`opentelemetry-exporter-otlp`, `pytest` + `pytest-asyncio` + `pytest-xdist`. OTLP/gRPC to `localhost:4317`.

**Scope note:** This plan implements spec phases 1–2 from `docs/superpowers/specs/2026-04-24-otel-observability-design.md`. Spec phases 3–8 (federation, metrics, audit, harness, bots, feature doc finalization) each become their own plan/PR.

**Out of scope for this plan (each gets its own plan later):**

- Federation trace context propagation on S2S (`ServerLink._dispatch` / `relay_event`). We inject traceparent on client-side send; federation relay is phase 3.
- Metrics pillar (`culture/telemetry/metrics.py`, counters, histograms).
- Audit JSONL sink (`culture/telemetry/audit.py`).
- Harness-side telemetry (`packages/agent-harness/telemetry.py` + all-backends citations).
- Bot aiohttp instrumentation.

---

## File Structure

**New files:**

- `culture/telemetry/__init__.py` — public re-exports (`init_telemetry`, `extract_traceparent_from_tags`, `inject_traceparent_into_tags`).
- `culture/telemetry/tracing.py` — TracerProvider init, no-op fallback, span helpers.
- `culture/telemetry/context.py` — W3C traceparent extract/inject against IRCv3 `Message.tags`.
- `culture/protocol/extensions/tracing.md` — protocol extension doc (tag names, caps, mitigation).
- `docs/agentirc/telemetry.md` — operator-facing feature doc.
- `docs/agentirc/otelcol-template.yaml` — copy-paste collector starter config.
- `tests/telemetry/__init__.py` — empty package marker.
- `tests/telemetry/test_context.py` — W3C ↔ IRCv3 tag roundtrip + cap/mitigation tests.
- `tests/telemetry/test_tracing.py` — `init_telemetry` on/off behavior.
- `tests/telemetry/test_server_tracing.py` — integration: client sends PRIVMSG → assert span tree.

**Modified files:**

- `pyproject.toml` — add OTEL dependencies; version bump to `8.2.0`.
- `uv.lock` — regenerated by `uv sync`.
- `culture/agentirc/config.py` — new `TelemetryConfig` dataclass + `telemetry` field on `ServerConfig`.
- `culture/config.py` — load `telemetry:` block from `server.yaml` into the outer `ServerConfig`, pass through to `agentirc.ServerConfig` at IRCd construction.
- `culture/agentirc/ircd.py` — call `init_telemetry` in `__init__`; span-wrap `emit_event`.
- `culture/agentirc/client.py` — span-wrap `_dispatch`, inject traceparent in `send`/`send_raw`, parse-error span events.
- `tests/conftest.py` — add `telemetry` pytest fixture (in-memory span exporter).
- `CHANGELOG.md` — updated by `/version-bump minor`.

**Unchanged (deliberate, per spec Section 3):**

- `culture/protocol/message.py` — no instrumentation in `parse`/`format` (hot path).

---

## Tasks

### Task 1: Protocol extension doc

**Files:**

- Create: `culture/protocol/extensions/tracing.md`

- [ ] **Step 1: Write the doc**

Write `culture/protocol/extensions/tracing.md` with exactly this content:

````markdown
# Extension: W3C Trace Context over IRC

**Tags:**

- `culture.dev/traceparent` — the W3C `traceparent` header value, verbatim (55 chars, format `00-<32-hex-trace-id>-<16-hex-parent-id>-<2-hex-flags>`). MUST match the W3C regex. If malformed, servers MUST silently drop the tag (treat as absent).
- `culture.dev/tracestate` — the W3C `tracestate` header value (comma-separated `key=value` list, vendor-specific). Optional. MUST be ≤ 512 bytes after IRCv3 unescape. If longer, servers MUST drop the tag.

**Scope:** outbound on every client-originated message when a span context is active (PRIVMSG, NOTICE, JOIN, PART, KICK, MODE, SEVENT, SMSG, SNOTICE). On federation relay, the tags are re-injected from the *current* server's span context — they are not copied verbatim from the received message. This produces a parent-per-hop span tree instead of collapsing all hops into one.

**Inbound handling.** On every ingress (local client `_dispatch` and S2S `_dispatch`):

1. Tag absent → start a new root span. Attribute: `culture.trace.origin=local`.
2. Tag present and valid → start a child span linked to the extracted context. Attributes: `culture.trace.origin=remote`, `culture.federation.peer=<peer>`.
3. Tag present but malformed or over the length cap → drop the tag, start a new root span. Attributes: `culture.trace.origin=remote`, `culture.trace.dropped_reason=malformed|too_long`, `culture.federation.peer=<peer>`. Log a rate-limited warning. Increment `culture.trace.inbound{result=malformed|too_long, peer=<peer>}`.

**Length caps** (hard-coded, not configurable):

- `traceparent`: exactly 55 characters.
- `tracestate`: ≤ 512 bytes post-unescape.

**Non-goal.** Trace context is not authenticated. Federation peer trust is the authorization mechanism; tracing is observability only. An operator who cares about forged trace IDs from upstream peers should rely on peer trust, not on tag validation.

**Compat.** Additive IRCv3 tag. Peers that don't recognize the tag pass it through on relay (standard IRCv3 tag behavior). No wire version bump. Project version bumps as a minor per `CLAUDE.md`.

**Example.**

User on `spark` sends:

```
@culture.dev/traceparent=00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 PRIVMSG #general :hi
```

Server `spark` starts span `irc.command.PRIVMSG` as a child of the extracted context, relays the event via SEVENT to `thor`, re-injecting its own span's traceparent. Server `thor` sees trace ID `4bf92f35…4736` and starts another child span — the collector stitches both spans into one trace.
````

- [ ] **Step 2: Commit**

```bash
git add protocol/extensions/tracing.md
git commit -m "docs(protocol): add tracing.md extension for W3C trace context over IRC

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 2: Add OTEL dependencies

**Files:**

- Modify: `pyproject.toml`
- Modify: `uv.lock`

- [ ] **Step 1: Add dependencies**

In `pyproject.toml`, inside the `[project] dependencies = [...]` list (currently ends with `"afi-cli>=0.3,<1.0",`), add four lines:

```toml
dependencies = [
    "pyyaml>=6.0",
    "anthropic>=0.40",
    "claude-agent-sdk>=0.1",
    "mistune>=3.0",
    "aiohttp>=3.9",
    "textual>=1.0",
    "agex-cli>=0.13,<1.0",
    "afi-cli>=0.3,<1.0",
    "opentelemetry-api>=1.25",
    "opentelemetry-sdk>=1.25",
    "opentelemetry-exporter-otlp-proto-grpc>=1.25",
    "opentelemetry-semantic-conventions>=0.46b0",
]
```

- [ ] **Step 2: Regenerate lockfile**

Run:

```bash
uv sync
```

Expected: `uv.lock` updated with the four new packages plus transitive deps (`googleapis-common-protos`, `grpcio`, `protobuf`, `opentelemetry-proto`, `opentelemetry-exporter-otlp-proto-common`, `opentelemetry-instrumentation` is NOT needed yet — it comes in the bot-instrumentation plan later).

- [ ] **Step 3: Verify import**

Run:

```bash
uv run python -c "from opentelemetry import trace; from opentelemetry.sdk.trace import TracerProvider; from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter; print('ok')"
```

Expected output: `ok`.

- [ ] **Step 4: Commit**

```bash
git add pyproject.toml uv.lock
git commit -m "deps: add opentelemetry-api/sdk/exporter-otlp-proto-grpc

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 3: TelemetryConfig dataclass

**Files:**

- Modify: `culture/agentirc/config.py`
- Test: `tests/telemetry/test_config.py` (new file, plus `tests/telemetry/__init__.py`)

- [ ] **Step 1: Create the test package marker**

```bash
touch tests/telemetry/__init__.py
```

- [ ] **Step 2: Write the failing test**

Write `tests/telemetry/test_config.py`:

```python
from culture.agentirc.config import ServerConfig, TelemetryConfig


def test_default_server_config_has_telemetry_disabled():
    cfg = ServerConfig()
    assert cfg.telemetry.enabled is False
    assert cfg.telemetry.service_name == "culture.agentirc"
    assert cfg.telemetry.otlp_endpoint == "http://localhost:4317"
    assert cfg.telemetry.otlp_timeout_ms == 5000
    assert cfg.telemetry.traces_enabled is True
    assert cfg.telemetry.traces_sampler == "parentbased_always_on"


def test_telemetry_config_accepts_overrides():
    t = TelemetryConfig(
        enabled=True,
        service_name="culture.agentirc.alpha",
        otlp_endpoint="http://collector:4317",
        otlp_timeout_ms=2000,
        traces_sampler="parentbased_traceidratio:0.1",
    )
    assert t.enabled is True
    assert t.service_name == "culture.agentirc.alpha"
    assert t.otlp_endpoint == "http://collector:4317"
    assert t.otlp_timeout_ms == 2000
    assert t.traces_sampler == "parentbased_traceidratio:0.1"
```

- [ ] **Step 3: Run test to verify it fails**

Run: `uv run pytest tests/telemetry/test_config.py -v`
Expected: `ImportError` — `TelemetryConfig` doesn't exist yet.

- [ ] **Step 4: Implement `TelemetryConfig`**

In `culture/agentirc/config.py`, add `TelemetryConfig` and extend `ServerConfig`:

```python
from dataclasses import dataclass, field


@dataclass
class LinkConfig:
    """Configuration for a server-to-server link."""

    name: str
    host: str
    port: int
    password: str
    trust: str = "full"  # "full" or "restricted"


@dataclass
class TelemetryConfig:
    """OpenTelemetry settings. Mirrors server.yaml `telemetry:` block."""

    enabled: bool = False
    service_name: str = "culture.agentirc"
    otlp_endpoint: str = "http://localhost:4317"
    otlp_protocol: str = "grpc"  # grpc | http/protobuf (only grpc supported initially)
    otlp_timeout_ms: int = 5000
    otlp_compression: str = "gzip"  # gzip | none
    traces_enabled: bool = True
    traces_sampler: str = "parentbased_always_on"


@dataclass
class ServerConfig:
    """Configuration for a culture server instance."""

    name: str = "culture"
    host: str = "0.0.0.0"
    port: int = 6667
    webhook_port: int = 7680
    data_dir: str = ""
    links: list[LinkConfig] = field(default_factory=list)
    system_bots: dict = field(default_factory=dict)
    telemetry: TelemetryConfig = field(default_factory=TelemetryConfig)
```

- [ ] **Step 5: Run test to verify it passes**

Run: `uv run pytest tests/telemetry/test_config.py -v`
Expected: 2 passed.

- [ ] **Step 6: Commit**

```bash
git add culture/agentirc/config.py tests/telemetry/__init__.py tests/telemetry/test_config.py
git commit -m "feat(telemetry): add TelemetryConfig dataclass on ServerConfig

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 4: Bridge telemetry from outer server.yaml loader to agentirc ServerConfig

**Files:**

- Modify: `culture/config.py`
- Test: `tests/telemetry/test_config_load.py` (new)

**Context:** `culture/config.py::ServerConfig` is the outer config loaded from `~/.culture/server.yaml`. `culture/agentirc/config.py::ServerConfig` is the runtime config passed to `IRCd`. The CLI (`culture server start`) builds the inner from the outer. We need the outer loader to accept a `telemetry:` block and pass it through.

- [ ] **Step 1: Write the failing test**

Write `tests/telemetry/test_config_load.py`:

```python
import yaml

from culture.config import ServerConfig as OuterServerConfig
from culture.config import load_server_config


def _write_yaml(tmp_path, data):
    p = tmp_path / "server.yaml"
    p.write_text(yaml.safe_dump(data))
    return p


def test_load_server_config_default_telemetry(tmp_path):
    p = _write_yaml(tmp_path, {"server": {"name": "alpha"}})
    cfg = load_server_config(p)
    assert cfg.telemetry.enabled is False
    assert cfg.telemetry.service_name == "culture.agentirc"


def test_load_server_config_custom_telemetry(tmp_path):
    p = _write_yaml(
        tmp_path,
        {
            "server": {"name": "alpha"},
            "telemetry": {
                "enabled": True,
                "service_name": "culture.agentirc.alpha",
                "otlp_endpoint": "http://collector:4317",
                "traces_sampler": "parentbased_traceidratio:0.1",
            },
        },
    )
    cfg = load_server_config(p)
    assert cfg.telemetry.enabled is True
    assert cfg.telemetry.service_name == "culture.agentirc.alpha"
    assert cfg.telemetry.otlp_endpoint == "http://collector:4317"
    assert cfg.telemetry.traces_sampler == "parentbased_traceidratio:0.1"
```

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/telemetry/test_config_load.py -v`
Expected: `AttributeError: 'ServerConfig' object has no attribute 'telemetry'`.

- [ ] **Step 3: Extend `culture/config.py` to carry telemetry**

In `culture/config.py`:

1. Import `TelemetryConfig` near the other dataclass imports:

   ```python
   from culture.agentirc.config import TelemetryConfig
   ```

2. Add a `telemetry` field to the outer `ServerConfig`:

   ```python
   @dataclass
   class ServerConfig:
       """Server configuration from server.yaml."""

       server: ServerConnConfig = field(default_factory=ServerConnConfig)
       supervisor: SupervisorConfig = field(default_factory=SupervisorConfig)
       webhooks: WebhookConfig = field(default_factory=WebhookConfig)
       telemetry: TelemetryConfig = field(default_factory=TelemetryConfig)
       buffer_size: int = 500
       # ... unchanged below
   ```

3. Populate it in `load_server_config`. The existing function reads `raw["server"]`, `raw["supervisor"]`, `raw["webhooks"]`. Add:

   ```python
   telemetry = TelemetryConfig(**raw.get("telemetry", {}))
   ```

   and pass it into the `ServerConfig(...)` constructor at the bottom of the function:

   ```python
   return ServerConfig(
       server=server,
       supervisor=supervisor,
       webhooks=webhooks,
       telemetry=telemetry,
       buffer_size=raw.get("buffer_size", 500),
       # ... unchanged
   )
   ```

4. Do the same for `_load_legacy_config` (same shape). Unknown keys in `raw["telemetry"]` will raise `TypeError` — that's the correct, fail-loud behavior for a typo'd key.

5. In `save_server_config`, include telemetry in the serialized dict:

   ```python
   data = {
       "server": asdict(config.server),
       "supervisor": asdict(config.supervisor),
       "webhooks": asdict(config.webhooks),
       "telemetry": asdict(config.telemetry),
       "buffer_size": config.buffer_size,
       # ... unchanged
   }
   ```

- [ ] **Step 4: Run test to verify it passes**

Run: `uv run pytest tests/telemetry/test_config_load.py -v`
Expected: 2 passed.

- [ ] **Step 5: Also run the full existing config test module to make sure nothing regressed**

Run: `uv run pytest tests/test_culture_config.py -v`
Expected: same pass count as before (existing tests should still pass — the new field has a default).

- [ ] **Step 6: Commit**

```bash
git add culture/config.py tests/telemetry/test_config_load.py
git commit -m "feat(telemetry): thread TelemetryConfig through server.yaml loader

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 5: Context helpers — extract/inject W3C traceparent in IRCv3 tags

**Files:**

- Create: `culture/telemetry/__init__.py`
- Create: `culture/telemetry/context.py`
- Test: `tests/telemetry/test_context.py`

- [ ] **Step 1: Write the failing test**

Write `tests/telemetry/test_context.py`:

```python
import pytest

from culture.protocol.message import Message
from culture.telemetry.context import (
    TRACEPARENT_TAG,
    TRACESTATE_TAG,
    ExtractResult,
    extract_traceparent_from_tags,
    inject_traceparent,
)

VALID_TP = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"


def test_extract_absent_returns_missing():
    msg = Message(command="PRIVMSG", params=["#c", "hi"])
    result = extract_traceparent_from_tags(msg, peer=None)
    assert result.status == "missing"
    assert result.traceparent is None
    assert result.tracestate is None


def test_extract_valid_passes_through():
    msg = Message(
        tags={TRACEPARENT_TAG: VALID_TP, TRACESTATE_TAG: "vendor=abc"},
        command="PRIVMSG",
        params=["#c", "hi"],
    )
    result = extract_traceparent_from_tags(msg, peer="thor")
    assert result.status == "valid"
    assert result.traceparent == VALID_TP
    assert result.tracestate == "vendor=abc"
    assert result.peer == "thor"


def test_extract_malformed_traceparent_is_dropped():
    msg = Message(tags={TRACEPARENT_TAG: "not-a-traceparent"}, command="PRIVMSG")
    result = extract_traceparent_from_tags(msg, peer="thor")
    assert result.status == "malformed"
    assert result.traceparent is None


def test_extract_wrong_length_traceparent_is_dropped():
    # Valid hex, but wrong length (trace-id is 30 hex instead of 32)
    bad = "00-4bf92f3577b34da6a3ce929d0e0e47-00f067aa0ba902b7-01"
    msg = Message(tags={TRACEPARENT_TAG: bad}, command="PRIVMSG")
    result = extract_traceparent_from_tags(msg, peer=None)
    assert result.status == "malformed"


def test_extract_oversize_tracestate_is_dropped_tp_retained():
    oversize = "x=" + ("y" * 520)
    msg = Message(
        tags={TRACEPARENT_TAG: VALID_TP, TRACESTATE_TAG: oversize},
        command="PRIVMSG",
    )
    result = extract_traceparent_from_tags(msg, peer=None)
    assert result.status == "valid"
    assert result.traceparent == VALID_TP
    assert result.tracestate is None  # dropped for length


def test_inject_roundtrip():
    msg = Message(command="PRIVMSG", params=["#c", "hi"])
    inject_traceparent(msg, traceparent=VALID_TP, tracestate="vendor=abc")
    assert msg.tags[TRACEPARENT_TAG] == VALID_TP
    assert msg.tags[TRACESTATE_TAG] == "vendor=abc"

    result = extract_traceparent_from_tags(msg, peer=None)
    assert result.status == "valid"
    assert result.traceparent == VALID_TP
    assert result.tracestate == "vendor=abc"


def test_inject_none_tracestate_does_not_set_tag():
    msg = Message(command="PRIVMSG", params=["#c", "hi"])
    inject_traceparent(msg, traceparent=VALID_TP, tracestate=None)
    assert TRACEPARENT_TAG in msg.tags
    assert TRACESTATE_TAG not in msg.tags


def test_wire_roundtrip_through_parse_format():
    msg = Message(command="PRIVMSG", params=["#c", "hi"])
    inject_traceparent(msg, traceparent=VALID_TP, tracestate="vendor=abc")
    wire = msg.format()
    reparsed = Message.parse(wire)
    result = extract_traceparent_from_tags(reparsed, peer="alpha")
    assert result.status == "valid"
    assert result.traceparent == VALID_TP
    assert result.tracestate == "vendor=abc"
```

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/telemetry/test_context.py -v`
Expected: `ModuleNotFoundError: No module named 'culture.telemetry'`.

- [ ] **Step 3: Create the package and context module**

Create `culture/telemetry/__init__.py`:

```python
"""OpenTelemetry integration for Culture.

Public surface re-exported here; call sites import from `culture.telemetry`.
"""

from culture.telemetry.context import (
    TRACEPARENT_TAG,
    TRACESTATE_TAG,
    ExtractResult,
    extract_traceparent_from_tags,
    inject_traceparent,
)

__all__ = [
    "TRACEPARENT_TAG",
    "TRACESTATE_TAG",
    "ExtractResult",
    "extract_traceparent_from_tags",
    "inject_traceparent",
]
```

Create `culture/telemetry/context.py`:

```python
"""W3C trace-context extraction and injection for IRCv3 message tags."""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Literal

from culture.protocol.message import Message

TRACEPARENT_TAG = "culture.dev/traceparent"
TRACESTATE_TAG = "culture.dev/tracestate"

# W3C traceparent: version-traceid-parentid-flags, all lowercase hex, exact lengths.
#   version:  2 hex (must be "00" for W3C v1)
#   trace-id: 32 hex, not all zero
#   parent-id: 16 hex, not all zero
#   flags:    2 hex
_TRACEPARENT_LEN = 55
_TRACEPARENT_RE = re.compile(
    r"^00-(?!0{32})[0-9a-f]{32}-(?!0{16})[0-9a-f]{16}-[0-9a-f]{2}$"
)
_TRACESTATE_MAX_BYTES = 512

ExtractStatus = Literal["missing", "valid", "malformed", "too_long"]


@dataclass(frozen=True)
class ExtractResult:
    """Outcome of parsing trace context from a Message's IRCv3 tags.

    Status meanings:
      - missing: no traceparent tag on the message.
      - valid: traceparent passed length+regex checks; tracestate (if any
        and within length cap) is also carried.
      - malformed: traceparent failed the W3C regex or length check.
      - too_long: traceparent tag itself was a valid shape but too long —
        currently unused, `malformed` covers all bad cases since the regex
        is anchored. Kept for forward compatibility with future W3C versions
        that may permit longer values.
    """

    status: ExtractStatus
    traceparent: str | None
    tracestate: str | None
    peer: str | None


def extract_traceparent_from_tags(msg: Message, peer: str | None) -> ExtractResult:
    """Extract W3C trace context from an incoming IRC message's tags.

    Applies the inbound mitigation rules from `culture/protocol/extensions/tracing.md`:
    absent → missing; length/regex failure → malformed; oversize tracestate
    dropped while traceparent retained.
    """
    raw_tp = msg.tags.get(TRACEPARENT_TAG)
    if raw_tp is None:
        return ExtractResult(status="missing", traceparent=None, tracestate=None, peer=peer)

    if len(raw_tp) != _TRACEPARENT_LEN or not _TRACEPARENT_RE.match(raw_tp):
        return ExtractResult(status="malformed", traceparent=None, tracestate=None, peer=peer)

    raw_ts = msg.tags.get(TRACESTATE_TAG)
    if raw_ts is not None and len(raw_ts.encode("utf-8")) > _TRACESTATE_MAX_BYTES:
        raw_ts = None

    return ExtractResult(status="valid", traceparent=raw_tp, tracestate=raw_ts, peer=peer)


def inject_traceparent(msg: Message, traceparent: str, tracestate: str | None) -> None:
    """Inject W3C trace context into an outgoing Message's IRCv3 tags.

    Mutates `msg.tags` in place — the message will carry these tags on wire.
    No validation: caller is expected to pass well-formed W3C values.
    """
    msg.tags[TRACEPARENT_TAG] = traceparent
    if tracestate is not None:
        msg.tags[TRACESTATE_TAG] = tracestate
```

- [ ] **Step 4: Run test to verify it passes**

Run: `uv run pytest tests/telemetry/test_context.py -v`
Expected: 8 passed.

- [ ] **Step 5: Commit**

```bash
git add culture/telemetry/__init__.py culture/telemetry/context.py tests/telemetry/test_context.py
git commit -m "feat(telemetry): W3C traceparent extract/inject helpers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 6: `init_telemetry` with on/off behavior

**Files:**

- Create: `culture/telemetry/tracing.py`
- Modify: `culture/telemetry/__init__.py` (re-export)
- Test: `tests/telemetry/test_tracing.py`

- [ ] **Step 1: Write the failing test**

Write `tests/telemetry/test_tracing.py`:

```python
from unittest.mock import patch

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider

from culture.agentirc.config import ServerConfig, TelemetryConfig
from culture.telemetry import init_telemetry
from culture.telemetry.tracing import reset_for_tests


def setup_function():
    reset_for_tests()


def teardown_function():
    reset_for_tests()


def test_init_disabled_returns_noop_tracer():
    cfg = ServerConfig(name="spark", telemetry=TelemetryConfig(enabled=False))
    tracer = init_telemetry(cfg)
    # NoOpTracer spans are context managers that do nothing.
    with tracer.start_as_current_span("test") as span:
        # NoOp spans have no recording behavior; is_recording() is False.
        assert span.is_recording() is False


def test_init_enabled_creates_sdk_tracer():
    cfg = ServerConfig(
        name="spark",
        telemetry=TelemetryConfig(enabled=True, otlp_endpoint="http://localhost:4317"),
    )
    with patch(
        "culture.telemetry.tracing.OTLPSpanExporter"
    ) as mock_exporter:  # avoid real gRPC connection in tests
        tracer = init_telemetry(cfg)
    mock_exporter.assert_called_once()
    call_kwargs = mock_exporter.call_args.kwargs
    assert call_kwargs["endpoint"] == "http://localhost:4317"
    # Tracer should resolve to the SDK provider we installed.
    provider = trace.get_tracer_provider()
    assert isinstance(provider, SdkTracerProvider)


def test_init_is_idempotent():
    cfg = ServerConfig(name="spark", telemetry=TelemetryConfig(enabled=False))
    t1 = init_telemetry(cfg)
    t2 = init_telemetry(cfg)
    assert t1 is t2
```

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/telemetry/test_tracing.py -v`
Expected: `ModuleNotFoundError` for `culture.telemetry.tracing`.

- [ ] **Step 3: Implement `tracing.py`**

Create `culture/telemetry/tracing.py`:

```python
"""OpenTelemetry TracerProvider bootstrap for Culture.

`init_telemetry(config)` is idempotent — safe to call from multiple places
(e.g. IRCd.__init__ and ServerLink.__init__ for independent test servers).
When `config.telemetry.enabled` is False, returns a no-op tracer without
touching the global provider.
"""

from __future__ import annotations

import logging

from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.trace.sampling import (
    ALWAYS_ON,
    ParentBased,
    TraceIdRatioBased,
)
from opentelemetry.trace import Tracer

from culture.agentirc.config import ServerConfig, TelemetryConfig

logger = logging.getLogger(__name__)

_CULTURE_TRACER_NAME = "culture"
_initialized_for: TelemetryConfig | None = None
_tracer: Tracer | None = None


def reset_for_tests() -> None:
    """Reset module state so each test gets a fresh provider. Test-only."""
    global _initialized_for, _tracer
    _initialized_for = None
    _tracer = None
    # Reset the global OTEL provider too, so one test's SDK doesn't leak.
    trace._TRACER_PROVIDER = None  # type: ignore[attr-defined]
    trace._TRACER_PROVIDER_SET_ONCE = trace.Once()  # type: ignore[attr-defined]


def _build_sampler(name: str):
    """Parse `sampler` string from TelemetryConfig into an OTEL Sampler."""
    if name == "parentbased_always_on":
        return ParentBased(ALWAYS_ON)
    if name.startswith("parentbased_traceidratio:"):
        ratio = float(name.split(":", 1)[1])
        return ParentBased(TraceIdRatioBased(ratio))
    if name == "always_off":
        from opentelemetry.sdk.trace.sampling import ALWAYS_OFF

        return ALWAYS_OFF
    logger.warning("Unknown sampler %r, falling back to parentbased_always_on", name)
    return ParentBased(ALWAYS_ON)


def init_telemetry(config: ServerConfig) -> Tracer:
    """Initialize the TracerProvider from a ServerConfig. Idempotent.

    Returns a Tracer bound to the "culture" instrumentation name. When
    `config.telemetry.enabled` is False, returns a no-op tracer and does
    not install an SDK provider — this keeps tests, and servers that opt
    out of telemetry, from paying any SDK cost.
    """
    global _initialized_for, _tracer

    tcfg = config.telemetry
    if _initialized_for is tcfg and _tracer is not None:
        return _tracer

    if not tcfg.enabled or not tcfg.traces_enabled:
        _tracer = trace.get_tracer(_CULTURE_TRACER_NAME)  # no-op when no provider set
        _initialized_for = tcfg
        return _tracer

    resource = Resource.create(
        {
            "service.name": tcfg.service_name,
            "service.instance.id": config.name,
        }
    )
    provider = TracerProvider(resource=resource, sampler=_build_sampler(tcfg.traces_sampler))
    exporter = OTLPSpanExporter(
        endpoint=tcfg.otlp_endpoint,
        timeout=tcfg.otlp_timeout_ms / 1000.0,
        compression=(
            None if tcfg.otlp_compression == "none" else tcfg.otlp_compression
        ),
    )
    provider.add_span_processor(BatchSpanProcessor(exporter))
    trace.set_tracer_provider(provider)

    _tracer = trace.get_tracer(_CULTURE_TRACER_NAME)
    _initialized_for = tcfg
    logger.info(
        "OTEL tracing initialized: service=%s instance=%s endpoint=%s sampler=%s",
        tcfg.service_name,
        config.name,
        tcfg.otlp_endpoint,
        tcfg.traces_sampler,
    )
    return _tracer
```

Update `culture/telemetry/__init__.py` to export `init_telemetry`:

```python
"""OpenTelemetry integration for Culture.

Public surface re-exported here; call sites import from `culture.telemetry`.
"""

from culture.telemetry.context import (
    TRACEPARENT_TAG,
    TRACESTATE_TAG,
    ExtractResult,
    extract_traceparent_from_tags,
    inject_traceparent,
)
from culture.telemetry.tracing import init_telemetry

__all__ = [
    "TRACEPARENT_TAG",
    "TRACESTATE_TAG",
    "ExtractResult",
    "extract_traceparent_from_tags",
    "init_telemetry",
    "inject_traceparent",
]
```

- [ ] **Step 4: Run test to verify it passes**

Run: `uv run pytest tests/telemetry/test_tracing.py -v`
Expected: 3 passed.

- [ ] **Step 5: Commit**

```bash
git add culture/telemetry/tracing.py culture/telemetry/__init__.py tests/telemetry/test_tracing.py
git commit -m "feat(telemetry): init_telemetry with no-op-when-disabled behavior

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 7: In-memory span exporter fixture for integration tests

**Files:**

- Modify: `tests/conftest.py`

- [ ] **Step 1: Add fixture**

Near the top of `tests/conftest.py`, after existing imports, add:

```python
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter

from culture.telemetry.tracing import reset_for_tests as _reset_telemetry
```

Then add the fixture (at the module level, near the other `pytest_asyncio.fixture` declarations):

```python
@pytest_asyncio.fixture
async def tracing_exporter():
    """In-memory span exporter for telemetry integration tests.

    Installs a dedicated SDK TracerProvider with a SimpleSpanProcessor so
    every finished span lands in the returned exporter synchronously. Cleans
    up after the test so it doesn't leak into parallel workers.
    """
    _reset_telemetry()
    exporter = InMemorySpanExporter()
    provider = SdkTracerProvider(resource=Resource.create({"service.name": "test"}))
    provider.add_span_processor(SimpleSpanProcessor(exporter))
    trace.set_tracer_provider(provider)
    try:
        yield exporter
    finally:
        exporter.clear()
        _reset_telemetry()
```

- [ ] **Step 2: Verify existing tests don't regress**

Run: `uv run pytest tests/test_connection.py -v --no-header -q 2>&1 | tail -5`
Expected: same pass count as before — fixture addition doesn't affect existing tests.

- [ ] **Step 3: Commit**

```bash
git add tests/conftest.py
git commit -m "test(telemetry): add tracing_exporter fixture

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 8: Wire `init_telemetry` into `IRCd.__init__`

**Files:**

- Modify: `culture/agentirc/ircd.py`
- Test: `tests/telemetry/test_server_init.py` (new)

- [ ] **Step 1: Write the failing test**

Write `tests/telemetry/test_server_init.py`:

```python
import pytest

from culture.agentirc.config import ServerConfig, TelemetryConfig
from culture.agentirc.ircd import IRCd


def test_ircd_init_sets_tracer_attribute():
    cfg = ServerConfig(name="spark", telemetry=TelemetryConfig(enabled=False))
    ircd = IRCd(cfg)
    assert ircd.tracer is not None
    # No-op tracer can still produce spans; is_recording will be False
    with ircd.tracer.start_as_current_span("check") as span:
        assert span.is_recording() is False


def test_ircd_init_respects_telemetry_enabled(tracing_exporter):
    # tracing_exporter fixture already installed an SDK provider; IRCd.init_telemetry
    # with enabled=False must NOT tear that down (it's a no-op path).
    cfg = ServerConfig(name="spark", telemetry=TelemetryConfig(enabled=False))
    ircd = IRCd(cfg)
    # Emit a span via the exporter's provider — must land in the exporter.
    from opentelemetry import trace

    trace.get_tracer("smoke").start_as_current_span("smoke").__enter__().__exit__(None, None, None)
    assert len(tracing_exporter.get_finished_spans()) == 1
```

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/telemetry/test_server_init.py -v`
Expected: `AttributeError: 'IRCd' object has no attribute 'tracer'`.

- [ ] **Step 3: Wire in `init_telemetry`**

In `culture/agentirc/ircd.py`, at the top of `IRCd.__init__` (the existing method starting at line 35), add two lines:

```python
    def __init__(self, config: ServerConfig):
        from culture.telemetry import init_telemetry

        self.config = config
        self.tracer = init_telemetry(config)
        self.clients: dict[str, Client | VirtualClient] = {}  # nick -> Client
        # ... rest unchanged
```

The import is local to avoid a circular import risk between `culture.telemetry` and `culture.agentirc` at module-load time.

- [ ] **Step 4: Run test to verify it passes**

Run: `uv run pytest tests/telemetry/test_server_init.py -v`
Expected: 2 passed.

- [ ] **Step 5: Run full server test suite for regressions**

Run: `uv run pytest tests/test_connection.py tests/test_messaging.py tests/test_channel.py -v --no-header -q 2>&1 | tail -10`
Expected: all pass (no behavior change when telemetry disabled).

- [ ] **Step 6: Commit**

```bash
git add culture/agentirc/ircd.py tests/telemetry/test_server_init.py
git commit -m "feat(telemetry): init_telemetry on IRCd bootstrap

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 9: Instrument `IRCd.emit_event` with a span

**Files:**

- Modify: `culture/agentirc/ircd.py`
- Test: `tests/telemetry/test_emit_event_span.py` (new)

- [ ] **Step 1: Write the failing test**

Write `tests/telemetry/test_emit_event_span.py`:

```python
import pytest

from culture.agentirc.config import ServerConfig, TelemetryConfig
from culture.agentirc.ircd import IRCd
from culture.agentirc.skill import Event, EventType


@pytest.mark.asyncio
async def test_emit_event_creates_span(tracing_exporter):
    cfg = ServerConfig(name="spark", telemetry=TelemetryConfig(enabled=False))
    # IRCd uses the module-level tracer from telemetry.tracing; the fixture
    # installed a real SDK provider so spans land in the exporter regardless
    # of telemetry.enabled.
    ircd = IRCd(cfg)
    await ircd.emit_event(
        Event(type=EventType.MESSAGE, channel="#c", nick="spark-alice", data={"body": "hi"})
    )
    spans = tracing_exporter.get_finished_spans()
    names = [s.name for s in spans]
    assert "irc.event.emit" in names
    event_span = next(s for s in spans if s.name == "irc.event.emit")
    assert event_span.attributes["event.type"] == "message"
    assert event_span.attributes["event.channel"] == "#c"
    assert event_span.attributes["event.origin"] == "local"


@pytest.mark.asyncio
async def test_emit_event_federated_origin(tracing_exporter):
    cfg = ServerConfig(name="spark", telemetry=TelemetryConfig(enabled=False))
    ircd = IRCd(cfg)
    await ircd.emit_event(
        Event(
            type=EventType.MESSAGE,
            channel="#c",
            nick="thor-bob",
            data={"body": "hi", "_origin": "thor"},
        )
    )
    spans = tracing_exporter.get_finished_spans()
    event_span = next(s for s in spans if s.name == "irc.event.emit")
    assert event_span.attributes["event.origin"] == "federated"
    assert event_span.attributes["culture.federation.peer"] == "thor"
```

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/telemetry/test_emit_event_span.py -v`
Expected: fails — `irc.event.emit` not in span names.

- [ ] **Step 3: Wrap `emit_event` body in a span**

In `culture/agentirc/ircd.py`, modify `emit_event` (lines 169–198). The module-level tracer is obtained once; don't import lazily in the hot path.

At module scope, after existing imports:

```python
from opentelemetry import trace as _otel_trace

_tracer = _otel_trace.get_tracer("culture.agentirc")
```

Then wrap the body of `emit_event`:

```python
    async def emit_event(self, event: Event) -> None:
        origin_tag = event.data.get("_origin")
        with _tracer.start_as_current_span(
            "irc.event.emit",
            attributes={
                "event.type": event.type.value,
                "event.channel": event.channel or "",
                "event.origin": "federated" if origin_tag else "local",
                **({"culture.federation.peer": origin_tag} if origin_tag else {}),
            },
        ):
            # 1) Sequence + log.
            seq = self.next_seq()
            self._event_log.append((seq, event))

            # 2) Run skill hooks.
            for skill in self.skills:
                try:
                    await skill.on_event(event)
                except Exception:
                    logger.exception("Skill %s failed on event %s", skill.name, event.type)

            # 3) Relay to linked peers — only relay locally-originated events
            # (no mesh routing; scope is direct peers only)
            if not origin_tag:
                for peer_name, link in list(self.links.items()):
                    try:
                        await link.relay_event(event)
                    except Exception:
                        logger.exception("Failed to relay event to %s", peer_name)

            # 4) Dispatch to event-triggered bots.
            if self.bot_manager is not None:
                try:
                    await self.bot_manager.on_event(event)
                except Exception:
                    logger.exception("bot_manager.on_event failed")

            # 5) Surface as tagged PRIVMSG from system-<server>.
            await self._surface_event_privmsg(event)
```

Note the small refactor: `event.data.get("_origin")` is now read once at the top and passed through; the existing `if not event.data.get("_origin"):` branch becomes `if not origin_tag:`.

- [ ] **Step 4: Run test to verify it passes**

Run: `uv run pytest tests/telemetry/test_emit_event_span.py -v`
Expected: 2 passed.

- [ ] **Step 5: Regression check**

Run: `uv run pytest tests/test_bot_manager.py tests/test_bots_integration.py -v --no-header -q 2>&1 | tail -10`
Expected: no regressions — `emit_event` behavior is unchanged when telemetry is a no-op tracer.

- [ ] **Step 6: Commit**

```bash
git add culture/agentirc/ircd.py tests/telemetry/test_emit_event_span.py
git commit -m "feat(telemetry): span-wrap IRCd.emit_event

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 10: Instrument `Client._dispatch` + traceparent extraction on inbound

**Files:**

- Modify: `culture/agentirc/client.py`
- Test: `tests/telemetry/test_dispatch_span.py` (new)

- [ ] **Step 1: Write the failing test**

Write `tests/telemetry/test_dispatch_span.py`:

```python
import pytest

from culture.agentirc.client import Client
from culture.agentirc.config import ServerConfig, TelemetryConfig
from culture.agentirc.ircd import IRCd
from culture.protocol.message import Message
from culture.telemetry.context import TRACEPARENT_TAG


class _FakeWriter:
    def __init__(self):
        self.buf = []

    def get_extra_info(self, key, default=None):
        return ("testaddr", 12345) if key == "peername" else default

    def write(self, data):
        self.buf.append(data)

    async def drain(self):
        pass


@pytest.mark.asyncio
async def test_dispatch_creates_command_span(tracing_exporter):
    cfg = ServerConfig(name="spark", telemetry=TelemetryConfig(enabled=False))
    ircd = IRCd(cfg)
    client = Client(reader=None, writer=_FakeWriter(), server=ircd)  # type: ignore[arg-type]
    client.nick = "spark-alice"
    await client._dispatch(Message(command="PING", params=["token1"]))
    names = [s.name for s in tracing_exporter.get_finished_spans()]
    assert "irc.command.PING" in names


@pytest.mark.asyncio
async def test_dispatch_extracts_traceparent(tracing_exporter):
    cfg = ServerConfig(name="spark", telemetry=TelemetryConfig(enabled=False))
    ircd = IRCd(cfg)
    client = Client(reader=None, writer=_FakeWriter(), server=ircd)  # type: ignore[arg-type]
    client.nick = "spark-alice"
    tp = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
    await client._dispatch(
        Message(tags={TRACEPARENT_TAG: tp}, command="PING", params=["token1"])
    )
    span = next(s for s in tracing_exporter.get_finished_spans() if s.name == "irc.command.PING")
    # Trace ID encoded from traceparent (middle hex group).
    assert format(span.context.trace_id, "032x") == "4bf92f3577b34da6a3ce929d0e0e4736"
    assert span.attributes["culture.trace.origin"] == "remote"


@pytest.mark.asyncio
async def test_dispatch_malformed_traceparent_drops_to_root(tracing_exporter):
    cfg = ServerConfig(name="spark", telemetry=TelemetryConfig(enabled=False))
    ircd = IRCd(cfg)
    client = Client(reader=None, writer=_FakeWriter(), server=ircd)  # type: ignore[arg-type]
    client.nick = "spark-alice"
    await client._dispatch(
        Message(tags={TRACEPARENT_TAG: "garbage"}, command="PING", params=["t"])
    )
    span = next(s for s in tracing_exporter.get_finished_spans() if s.name == "irc.command.PING")
    assert span.attributes["culture.trace.origin"] == "remote"
    assert span.attributes["culture.trace.dropped_reason"] == "malformed"
```

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/telemetry/test_dispatch_span.py -v`
Expected: fails — no `irc.command.*` span.

- [ ] **Step 3: Instrument `_dispatch`**

In `culture/agentirc/client.py`, at module scope after existing imports:

```python
from opentelemetry import trace as _otel_trace
from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags
from opentelemetry.trace.propagation import set_span_in_context

from culture.telemetry.context import extract_traceparent_from_tags

_tracer = _otel_trace.get_tracer("culture.agentirc")


def _context_from_traceparent(tp: str):
    """Build an OTEL context whose current span is a NonRecordingSpan
    synthesized from a W3C traceparent string. The `_dispatch` span we
    start next will be a child of this context."""
    # Format: 00-<trace-id>-<parent-id>-<flags>
    _, trace_hex, parent_hex, flags_hex = tp.split("-")
    span_ctx = SpanContext(
        trace_id=int(trace_hex, 16),
        span_id=int(parent_hex, 16),
        is_remote=True,
        trace_flags=TraceFlags(int(flags_hex, 16)),
    )
    return set_span_in_context(NonRecordingSpan(span_ctx))
```

Then modify `_dispatch` (currently starts at line 112):

```python
    async def _dispatch(self, msg: Message) -> None:
        extract = extract_traceparent_from_tags(msg, peer=None)
        parent_ctx = None
        if extract.status == "valid":
            parent_ctx = _context_from_traceparent(extract.traceparent)

        attrs = {
            "irc.command": msg.command,
            "irc.prefix_nick": (msg.prefix.split("!")[0] if msg.prefix else ""),
            "culture.trace.origin": "local" if extract.status == "missing" else "remote",
        }
        if extract.status in ("malformed", "too_long"):
            attrs["culture.trace.dropped_reason"] = extract.status

        with _tracer.start_as_current_span(
            f"irc.command.{msg.command}",
            context=parent_ctx,
            attributes=attrs,
        ):
            handler = getattr(self, f"_handle_{msg.command.lower()}", None)
            if handler:
                await maybe_await(handler(msg))
            else:
                skill = self.server.get_skill_for_command(msg.command)
                if skill and self._registered:
                    try:
                        await skill.on_command(self, msg)
                    except Exception:
                        logging.getLogger(__name__).exception(
                            "Skill %s failed on command %s", skill.name, msg.command
                        )
                else:
                    await self.send_numeric(
                        replies.ERR_UNKNOWNCOMMAND, msg.command, "Unknown command"
                    )
```

- [ ] **Step 4: Run test to verify it passes**

Run: `uv run pytest tests/telemetry/test_dispatch_span.py -v`
Expected: 3 passed.

- [ ] **Step 5: Regression check**

Run: `uv run pytest tests/test_connection.py tests/test_messaging.py -v --no-header -q 2>&1 | tail -10`
Expected: all pass.

- [ ] **Step 6: Commit**

```bash
git add culture/agentirc/client.py tests/telemetry/test_dispatch_span.py
git commit -m "feat(telemetry): span-wrap Client._dispatch + extract traceparent

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 11: Inject traceparent on outbound `Client.send` / `send_raw`

**Files:**

- Modify: `culture/agentirc/client.py`
- Test: `tests/telemetry/test_outbound_inject.py` (new)

- [ ] **Step 1: Write the failing test**

Write `tests/telemetry/test_outbound_inject.py`:

```python
import pytest

from culture.agentirc.client import Client
from culture.agentirc.config import ServerConfig, TelemetryConfig
from culture.agentirc.ircd import IRCd
from culture.protocol.message import Message
from culture.telemetry.context import TRACEPARENT_TAG


class _FakeWriter:
    def __init__(self):
        self.buf = []

    def get_extra_info(self, key, default=None):
        return ("testaddr", 12345) if key == "peername" else default

    def write(self, data):
        self.buf.append(data)

    async def drain(self):
        pass


@pytest.mark.asyncio
async def test_send_injects_traceparent_when_span_active(tracing_exporter):
    from opentelemetry import trace

    cfg = ServerConfig(name="spark", telemetry=TelemetryConfig(enabled=False))
    ircd = IRCd(cfg)
    client = Client(reader=None, writer=_FakeWriter(), server=ircd)  # type: ignore[arg-type]
    client.caps = {"message-tags"}
    tracer = trace.get_tracer("test")
    with tracer.start_as_current_span("outer"):
        await client.send(Message(command="PRIVMSG", params=["#c", "hi"]))
    wire = client.writer.buf[0].decode()
    assert TRACEPARENT_TAG in wire


@pytest.mark.asyncio
async def test_send_raw_injects_traceparent_when_span_active(tracing_exporter):
    from opentelemetry import trace

    cfg = ServerConfig(name="spark", telemetry=TelemetryConfig(enabled=False))
    ircd = IRCd(cfg)
    client = Client(reader=None, writer=_FakeWriter(), server=ircd)  # type: ignore[arg-type]
    tracer = trace.get_tracer("test")
    with tracer.start_as_current_span("outer"):
        await client.send_raw("PRIVMSG #c :hi")
    wire = client.writer.buf[0].decode()
    assert TRACEPARENT_TAG in wire


@pytest.mark.asyncio
async def test_send_no_injection_when_no_active_span(tracing_exporter):
    cfg = ServerConfig(name="spark", telemetry=TelemetryConfig(enabled=False))
    ircd = IRCd(cfg)
    client = Client(reader=None, writer=_FakeWriter(), server=ircd)  # type: ignore[arg-type]
    client.caps = {"message-tags"}
    # No active span → no injection
    await client.send(Message(command="PRIVMSG", params=["#c", "hi"]))
    wire = client.writer.buf[0].decode()
    assert TRACEPARENT_TAG not in wire
```

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/telemetry/test_outbound_inject.py -v`
Expected: fails.

- [ ] **Step 3: Add an `_inject_current_traceparent` helper and call it from `send`/`send_raw`**

In `culture/agentirc/client.py`, add a module-level helper after `_context_from_traceparent`:

```python
def _current_traceparent() -> str | None:
    """Return the W3C traceparent for the currently-active span, or None
    if no span is recording (e.g. no-op tracer / sampler dropped).
    """
    span = _otel_trace.get_current_span()
    ctx = span.get_span_context()
    if not ctx.is_valid:
        return None
    return (
        f"00-{format(ctx.trace_id, '032x')}-{format(ctx.span_id, '016x')}"
        f"-{format(int(ctx.trace_flags), '02x')}"
    )
```

Replace `Client.send` and `Client.send_raw`:

```python
    async def send(self, message: Message) -> None:
        tp = _current_traceparent()
        if tp is not None:
            from culture.telemetry.context import inject_traceparent as _inject

            _inject(message, traceparent=tp, tracestate=None)
        try:
            self.writer.write(message.format().encode("utf-8"))
            await self.writer.drain()
        except OSError:
            pass  # Client disconnected; cleanup happens in ircd._handle_connection

    async def send_raw(self, line: str) -> None:
        """Write a pre-formatted IRC line to the client socket.

        Appends CRLF internally, matching ServerLink.send_raw convention.
        Injects `culture.dev/traceparent` as an IRCv3 tag when a span is active.
        """
        tp = _current_traceparent()
        if tp is not None:
            prefix = f"@{_TP_TAG_NAME}={tp} "
            line = prefix + line
        try:
            self.writer.write(f"{line}\r\n".encode("utf-8"))
            await self.writer.drain()
        except OSError:
            pass  # Client disconnected; cleanup happens in ircd._handle_connection
```

And add the tag-name constant import at module scope:

```python
from culture.telemetry.context import (
    TRACEPARENT_TAG as _TP_TAG_NAME,
    extract_traceparent_from_tags,
)
```

(Replacing the previous single-line import of `extract_traceparent_from_tags` with this grouped form.)

- [ ] **Step 4: Run test to verify it passes**

Run: `uv run pytest tests/telemetry/test_outbound_inject.py -v`
Expected: 3 passed.

- [ ] **Step 5: Regression check**

Run: `uv run pytest tests/test_connection.py tests/test_messaging.py -v --no-header -q 2>&1 | tail -10`
Expected: all pass (injection is a no-op when no span active, which is the default state outside of `_dispatch`).

- [ ] **Step 6: Commit**

```bash
git add culture/agentirc/client.py tests/telemetry/test_outbound_inject.py
git commit -m "feat(telemetry): inject traceparent on Client.send and send_raw

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 12: Parse-error compensation in `Client._process_buffer`

**Files:**

- Modify: `culture/agentirc/client.py`
- Test: `tests/telemetry/test_parse_error.py` (new)

**Context:** per spec Section 3 compensation, parse exceptions become span events on the enclosing span — no new spans, no hot-path cost.

- [ ] **Step 1: Write the failing test**

Write `tests/telemetry/test_parse_error.py`:

```python
from unittest.mock import patch

import pytest

from culture.agentirc.client import Client
from culture.agentirc.config import ServerConfig, TelemetryConfig
from culture.agentirc.ircd import IRCd
from culture.protocol.message import Message


class _FakeWriter:
    def __init__(self):
        self.buf = []

    def get_extra_info(self, key, default=None):
        return ("testaddr", 12345) if key == "peername" else default

    def write(self, data):
        self.buf.append(data)

    async def drain(self):
        pass


@pytest.mark.asyncio
async def test_parse_error_surfaces_as_span_event(tracing_exporter):
    cfg = ServerConfig(name="spark", telemetry=TelemetryConfig(enabled=False))
    ircd = IRCd(cfg)
    client = Client(reader=None, writer=_FakeWriter(), server=ircd)  # type: ignore[arg-type]
    client.nick = "spark-alice"

    def _boom(_line):
        raise ValueError("bad line")

    # _process_buffer opens its own span "irc.client.process_buffer"; parse
    # errors from Message.parse are attached as events on that span.
    with patch.object(Message, "parse", side_effect=_boom):
        await client._process_buffer("GARBAGE\n")

    spans = tracing_exporter.get_finished_spans()
    buf_span = next(s for s in spans if s.name == "irc.client.process_buffer")
    event_names = [e.name for e in buf_span.events]
    assert "irc.parse_error" in event_names
    parse_evt = next(e for e in buf_span.events if e.name == "irc.parse_error")
    assert parse_evt.attributes["error"] == "ValueError"
    assert parse_evt.attributes["line_preview"].startswith("GARBAGE")
```

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/telemetry/test_parse_error.py -v`
Expected: fails.

- [ ] **Step 3: Wrap `_process_buffer`**

Replace `Client._process_buffer` in `culture/agentirc/client.py`:

```python
    async def _process_buffer(self, buffer: str) -> str:
        """Parse and dispatch all complete lines from buffer, return remainder."""
        with _tracer.start_as_current_span("irc.client.process_buffer") as span:
            while "\n" in buffer:
                line, buffer = buffer.split("\n", 1)
                if not line.strip():
                    continue
                try:
                    msg = Message.parse(line)
                except Exception as exc:  # noqa: BLE001 -- widen for any parser failure
                    span.add_event(
                        "irc.parse_error",
                        attributes={
                            "line_preview": line[:64],
                            "error": type(exc).__name__,
                        },
                    )
                    continue
                if msg.command:
                    await self._dispatch(msg)
            return buffer
```

- [ ] **Step 4: Run test to verify it passes**

Run: `uv run pytest tests/telemetry/test_parse_error.py -v`
Expected: 1 passed.

- [ ] **Step 5: Regression check**

Run: `uv run pytest tests/test_connection.py tests/test_messaging.py -v --no-header -q 2>&1 | tail -10`
Expected: all pass.

- [ ] **Step 6: Commit**

```bash
git add culture/agentirc/client.py tests/telemetry/test_parse_error.py
git commit -m "feat(telemetry): parse-error compensation as span events

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 13: PRIVMSG span instrumentation + message body attribute

**Files:**

- Modify: `culture/agentirc/client.py`
- Test: `tests/telemetry/test_privmsg_span.py` (new)

**Context:** per spec decision 2 (Question 2), full bodies are captured as span attributes. The existing `_handle_privmsg` method (l. 659 per spec) routes to `_send_to_channel` or `_send_to_client`. We wrap all three.

Current signatures (read and locked in — line numbers as of plan authoring):

- `async def _handle_privmsg(self, msg: Message) -> None:` — l. 659. `target = msg.params[0]`, `text = msg.params[1]`.
- `async def _send_to_channel(self, channel, target, relay, text, is_notice):` — l. 617. `text` is the body, `target` is the channel name.
- `async def _send_to_client(self, target, relay, text, is_notice):` — l. 633. `text` is the body, `target` is the recipient nick.

- [ ] **Step 1: Write the failing test**

Write `tests/telemetry/test_privmsg_span.py`:

```python
import asyncio

import pytest

from tests.conftest import IRCTestClient, TEST_LINK_PASSWORD


@pytest.mark.asyncio
async def test_channel_privmsg_produces_three_span_hierarchy(server, tracing_exporter):
    async def _connect(nick):
        reader, writer = await asyncio.open_connection("127.0.0.1", server.config.port)
        c = IRCTestClient(reader, writer)
        await c.send(f"NICK {nick}")
        await c.send(f"USER {nick} 0 * :{nick}")
        await c.recv_until("001")
        return c

    alice = await _connect("testserv-alice")
    bob = await _connect("testserv-bob")
    await alice.send("JOIN #c")
    await bob.send("JOIN #c")
    await alice.recv_until("JOIN")
    await bob.recv_until("JOIN")

    tracing_exporter.clear()
    await alice.send("PRIVMSG #c :hello world")
    await bob.recv_until("PRIVMSG #c :hello world")

    spans = tracing_exporter.get_finished_spans()
    names = [s.name for s in spans]
    assert "irc.command.PRIVMSG" in names
    assert "irc.privmsg.dispatch" in names
    assert "irc.privmsg.deliver.channel" in names

    # All three share one trace id
    trace_ids = {s.context.trace_id for s in spans if s.name in {
        "irc.command.PRIVMSG",
        "irc.privmsg.dispatch",
        "irc.privmsg.deliver.channel",
    }}
    assert len(trace_ids) == 1

    deliver = next(s for s in spans if s.name == "irc.privmsg.deliver.channel")
    assert deliver.attributes["irc.channel"] == "#c"
    assert deliver.attributes["irc.message.body"] == "hello world"
```

- [ ] **Step 2: Run test to verify it fails**

Run: `uv run pytest tests/telemetry/test_privmsg_span.py -v`
Expected: fails — `irc.privmsg.dispatch`/`irc.privmsg.deliver.channel` absent.

- [ ] **Step 3: Wrap `_handle_privmsg`**

Replace `_handle_privmsg` in `culture/agentirc/client.py` (starting l. 659) with:

```python
    async def _handle_privmsg(self, msg: Message) -> None:
        if len(msg.params) < 2:
            await self.send_numeric(
                replies.ERR_NEEDMOREPARAMS, "PRIVMSG", replies.MSG_NEEDMOREPARAMS
            )
            return

        target = msg.params[0]
        text = msg.params[1]
        with _tracer.start_as_current_span(
            "irc.privmsg.dispatch",
            attributes={
                "irc.target": target,
                "irc.message.body": text,
                "irc.message.size": len(text),
            },
        ):
            relay = Message(prefix=self.prefix, command="PRIVMSG", params=[target, text])

            if target.startswith("#"):
                channel = self.server.channels.get(target)
                if not channel:
                    await self.send_numeric(
                        replies.ERR_NOSUCHCHANNEL, target, replies.MSG_NOSUCHCHANNEL
                    )
                    return
                if self not in channel.members:
                    await self.send_numeric(
                        replies.ERR_CANNOTSENDTOCHAN, target, "Cannot send to channel"
                    )
                    return
                await self._send_to_channel(channel, target, relay, text, False)
                await self._notify_mentions(target, text)
            else:
                found = await self._send_to_client(target, relay, text, False)
                if not found:
                    await self.send_numeric(
                        replies.ERR_NOSUCHNICK, target, replies.MSG_NOSUCHNICK
                    )
                    return
                await self._notify_mentions(None, text)
```

- [ ] **Step 4: Wrap `_send_to_channel`**

Replace `_send_to_channel` in `culture/agentirc/client.py` (starting l. 617) with:

```python
    async def _send_to_channel(self, channel, target, relay, text, is_notice):
        with _tracer.start_as_current_span(
            "irc.privmsg.deliver.channel",
            attributes={
                "irc.channel": target,
                "irc.message.body": text,
                "irc.message.size": len(text),
                "irc.notice": is_notice,
            },
        ):
            for member in list(channel.members):
                if member is not self:
                    await member.send(relay)
            event_data = {"text": text}
            if is_notice:
                event_data["notice"] = True
            await self.server.emit_event(
                Event(
                    type=EventType.MESSAGE,
                    channel=target,
                    nick=self.nick,
                    data=event_data,
                )
            )
```

- [ ] **Step 5: Wrap `_send_to_client`**

Replace `_send_to_client` in `culture/agentirc/client.py` (starting l. 633) with:

```python
    async def _send_to_client(self, target, relay, text, is_notice):
        from culture.agentirc.remote_client import RemoteClient

        with _tracer.start_as_current_span(
            "irc.privmsg.deliver.dm",
            attributes={
                "irc.target.nick": target,
                "irc.message.body": text,
                "irc.message.size": len(text),
                "irc.notice": is_notice,
            },
        ):
            recipient = self.server.get_client(target)
            if not recipient:
                return False
            if isinstance(recipient, RemoteClient):
                s2s_cmd = "SNOTICE" if is_notice else "SMSG"
                await recipient.link.send_raw(
                    f":{self.server.config.name} {s2s_cmd} {target} {self.nick} :{text}"
                )
            else:
                await recipient.send(relay)
            event_data = {"text": text, "target": target}
            if is_notice:
                event_data["notice"] = True
            await self.server.emit_event(
                Event(
                    type=EventType.MESSAGE,
                    channel=None,
                    nick=self.nick,
                    data=event_data,
                )
            )
            return True
```

- [ ] **Step 6: Run test to verify it passes**

Run: `uv run pytest tests/telemetry/test_privmsg_span.py -v`
Expected: 1 passed.

- [ ] **Step 7: Regression check**

Run: `uv run pytest tests/test_messaging.py -v --no-header -q 2>&1 | tail -10`
Expected: all pass.

- [ ] **Step 8: Commit**

```bash
git add culture/agentirc/client.py tests/telemetry/test_privmsg_span.py
git commit -m "feat(telemetry): span-wrap PRIVMSG dispatch + delivery

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 14: Operator-facing feature doc

**Files:**

- Create: `docs/agentirc/telemetry.md`
- Create: `docs/agentirc/otelcol-template.yaml`

- [ ] **Step 1: Write `docs/agentirc/telemetry.md`**

Write:

````markdown
---
layout: default
title: Telemetry
parent: AgentIRC
nav_order: 90
---

# Telemetry

Culture ships with first-class OpenTelemetry support: traces for every IRC command and event, W3C trace context carried across federation via a new IRCv3 tag, and a local collector pattern that keeps Culture's surface small.

This page covers the **Foundation + Server Tracing** release (culture 8.2.0). Metrics, audit, harness instrumentation, and bot instrumentation ship in subsequent releases.

## What you get in 8.2.0

A single PRIVMSG from a connected client produces a trace with these spans:

```text
irc.command.PRIVMSG           (root, or child of client-supplied traceparent)
├── irc.privmsg.dispatch      (target + body attributes)
│   └── irc.privmsg.deliver.channel OR irc.privmsg.deliver.dm
│       └── irc.event.emit    (from IRCd.emit_event)
└── irc.client.process_buffer (wraps Message.parse + dispatch)
```

Every span is tagged with:

- `service.name=culture.agentirc` (or your override)
- `service.instance.id=<server_name>`

## Configuration

Telemetry is **off by default**. Enable it in `~/.culture/server.yaml`:

```yaml
telemetry:
  enabled: true
  service_name: culture.agentirc
  otlp_endpoint: http://localhost:4317
  otlp_protocol: grpc
  otlp_timeout_ms: 5000
  otlp_compression: gzip
  traces_enabled: true
  traces_sampler: parentbased_always_on
```

- `enabled: false` (default) → no SDK init, no export, no overhead. Traceparent tags on inbound messages are still parsed and validated (for the future mitigation metric), but no spans are created.
- `traces_sampler: parentbased_always_on` → accept upstream sampling decisions via W3C `traceparent` flags; sample everything otherwise. Alternative: `parentbased_traceidratio:0.1` for 10% sampling.

Standard OpenTelemetry env vars override YAML: `OTEL_SERVICE_NAME`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_TRACES_SAMPLER`.

## Running a local collector

Install `otelcol-contrib` from <https://github.com/open-telemetry/opentelemetry-collector-releases/releases>. Start with the template at `docs/agentirc/otelcol-template.yaml`:

```bash
otelcol-contrib --config=docs/agentirc/otelcol-template.yaml
```

The template ships with a `debug` exporter — traces print to stdout. Swap in Tempo, Loki, Grafana Cloud, Honeycomb, or any OTLP-compatible backend by editing the `exporters:` section.

## Trace context over IRC

When telemetry is enabled and a span is active, outbound client messages carry two IRCv3 tags:

- `culture.dev/traceparent` — W3C traceparent header value.
- `culture.dev/tracestate` — W3C tracestate (optional).

Protocol details, length caps, and inbound mitigation rules: see [`culture/protocol/extensions/tracing.md`](../../protocol/extensions/tracing.md).

## What's not in 8.2.0

The design spec at `docs/superpowers/specs/2026-04-24-otel-observability-design.md` covers the full three-pillar scope. These pieces ship in later releases:

- Federation trace-context relay across `ServerLink` (so a trace spans multiple servers).
- Metrics pillar (message counters, histograms, federation health).
- Audit JSONL sink.
- Harness-side tracing for `claude`/`codex`/`copilot`/`acp`.
- Bot webhook HTTP instrumentation.

Each will get an entry under **What you get in \<version\>** as it lands.
````

- [ ] **Step 2: Write `docs/agentirc/otelcol-template.yaml`**

Write:

```yaml
# otelcol-contrib starter config for Culture.
# Edit the `exporters:` and `service.pipelines` sections to point at your
# real backends (Tempo, Loki, Prometheus, Grafana Cloud, Honeycomb, etc.).

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317

processors:
  batch:
    timeout: 5s
    send_batch_size: 512

exporters:
  debug:
    verbosity: detailed

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [debug]
```

- [ ] **Step 3: Commit**

```bash
git add docs/agentirc/telemetry.md docs/agentirc/otelcol-template.yaml
git commit -m "docs(agentirc): telemetry feature doc + collector template

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 15: Doc-test alignment subagent pass

**Files:** none (audit only)

- [ ] **Step 1: Invoke the doc-test-alignment subagent**

Per `CLAUDE.md`: before first push, audit new public surface for doc coverage.

Run via the agent tool (not a shell command). Inside the current session, invoke:

```
Agent(
  subagent_type="doc-test-alignment",
  description="Audit docs for OTEL foundation branch",
  prompt="Audit the staged diff on branch `feature/otel-observability` for public-API surface changes and report any gaps in `docs/` or `protocol/extensions/`. New surface this plan introduces: (1) TelemetryConfig config fields in server.yaml; (2) IRCv3 tags `culture.dev/traceparent` and `culture.dev/tracestate`; (3) new exception class names — none in this plan (deferred). Check that `docs/agentirc/telemetry.md` mentions every config field and that `culture/protocol/extensions/tracing.md` covers both tags."
)
```

Address any gaps it flags by editing the relevant doc and committing a fix.

- [ ] **Step 2: If the subagent reported gaps, commit the fixes**

If edits were needed:

```bash
git add docs/agentirc/telemetry.md protocol/extensions/tracing.md
git commit -m "docs(telemetry): address doc-test-alignment audit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

If the audit passed cleanly, skip the commit.

---

### Task 16: Version bump

**Files:**

- Modify: `pyproject.toml`
- Modify: `CHANGELOG.md`

- [ ] **Step 1: Run `/version-bump minor`**

Use the `version-bump` skill to bump 8.1.0 → 8.2.0 and update `CHANGELOG.md`:

```
Skill(skill="version-bump", args="minor")
```

The skill updates `pyproject.toml`, `CHANGELOG.md`, and regenerates `uv.lock` automatically.

- [ ] **Step 2: Edit the CHANGELOG entry**

The skill creates a placeholder section. Replace it with:

```markdown
## [8.2.0] - 2026-04-24

### Added

- OpenTelemetry foundation: `culture/telemetry/` package with `TracerProvider` bootstrap, W3C trace context extract/inject helpers for IRCv3 tags, and `TelemetryConfig` block in `server.yaml`.
- Protocol extension: `culture.dev/traceparent` and `culture.dev/tracestate` IRCv3 tags (`culture/protocol/extensions/tracing.md`).
- Server-side tracing: `IRCd.emit_event`, `Client._dispatch`, `Client._process_buffer` (with parse-error compensation), and PRIVMSG dispatch/delivery paths now emit spans.
- Outbound traceparent injection on `Client.send` and `Client.send_raw` when a span is active.
- Operator docs at `docs/agentirc/telemetry.md` plus a starter collector config at `docs/agentirc/otelcol-template.yaml`.

### Dependencies

- Added `opentelemetry-api`, `opentelemetry-sdk`, `opentelemetry-exporter-otlp-proto-grpc`, `opentelemetry-semantic-conventions`.
```

- [ ] **Step 3: Commit**

```bash
git add pyproject.toml uv.lock CHANGELOG.md
git commit -m "chore(release): 8.2.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

---

### Task 17: Pre-push review, run full test suite, open PR

- [ ] **Step 1: Invoke the superpowers code-reviewer on the staged diff**

The branch touches transport (`Client.send` / `send_raw`) and a choke point (`_dispatch`) — exactly the "pre-push review for library/protocol code" case in `CLAUDE.md`.

```
Agent(
  subagent_type="superpowers:code-reviewer",
  description="Pre-push review of OTEL foundation diff",
  prompt="Review the full diff on branch `feature/otel-observability` vs `main`. Focus on: (1) traceparent injection in Client.send and send_raw — correctness under exception paths and when caps exceed `message-tags`, (2) the no-op-when-disabled invariant for `init_telemetry` — no global state leakage, (3) parse-error span-event compensation — does it suppress any currently-raised exception that other code paths rely on? (4) test isolation across pytest-xdist workers given `trace._TRACER_PROVIDER` is global. Report any issues with severity."
)
```

Address any high-severity findings with a fix commit; re-run until clean.

- [ ] **Step 2: Run the full test suite**

```
Skill(skill="run-tests")
```

Expected: all pass. If anything in the non-telemetry suite regressed, fix it before pushing — the tracing instrumentation must be transparent when disabled.

- [ ] **Step 3: Push the branch**

```bash
git push -u origin feature/otel-observability
```

- [ ] **Step 4: Open the PR**

```bash
gh pr create --title "feat(telemetry): OTEL foundation + server-side tracing core" --body "$(cat <<'EOF'
## Summary

First shippable increment of the OTEL observability initiative (spec: `docs/superpowers/specs/2026-04-24-otel-observability-design.md`, phases 1–2).

- New `culture/telemetry/` package with TracerProvider bootstrap and W3C context extract/inject helpers
- New IRCv3 tags `culture.dev/traceparent` / `culture.dev/tracestate` documented in `culture/protocol/extensions/tracing.md`
- Server-side spans on `IRCd.emit_event`, `Client._dispatch`, `Client._process_buffer`, and PRIVMSG dispatch/delivery
- Outbound traceparent injection when a span is active
- Feature doc + collector starter config at `docs/agentirc/`
- Telemetry off by default — zero overhead when disabled

## Out of scope (future PRs)

Federation relay, metrics, audit sink, harness instrumentation, bot webhook instrumentation. Each gets its own plan/PR.

## Test plan

- [ ] `/run-tests` all green
- [ ] Manual E2E: start a server with `telemetry.enabled: true` and `otelcol-contrib` using the template config; send a PRIVMSG; confirm the three-span tree in the collector's debug output

🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```

- [ ] **Step 5: Wait for automated reviewers, then use the PR skill**

Once Qodo and Copilot comment:

```
Skill(skill="cicd")  # renamed from "pr-review" in culture 8.8.1
```

Address comments, push fixes, reply, resolve threads.

- [ ] **Step 6: SonarCloud check before declaring ready**

Per `CLAUDE.md` pre-push checklist:

```
Skill(skill="sonarclaude")
```

Address any findings.

---

## Verification

**Automated:**

- `uv run pytest tests/telemetry/ -v` — all tests in the new package pass
- `uv run pytest` (full suite) — no regressions from instrumentation

**Manual end-to-end:**

1. Start `otelcol-contrib` with the template: `otelcol-contrib --config=docs/agentirc/otelcol-template.yaml`
2. In another terminal, put this in a test `~/.culture/server.yaml`:

   ```yaml
   server:
     name: sparktest
     host: 127.0.0.1
     port: 6667
   telemetry:
     enabled: true
     otlp_endpoint: http://localhost:4317
   ```

3. `culture server start --name sparktest`
4. Connect with `weechat` or `irssi`; join `#general`; send `PRIVMSG #general :hello`
5. Confirm the collector's stdout shows three spans sharing a `trace_id`:
   - `irc.command.PRIVMSG`
   - `irc.privmsg.dispatch`
   - `irc.privmsg.deliver.channel`
6. Confirm each span's resource attributes include `service.name=culture.agentirc` and `service.instance.id=sparktest`.
