Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ GitHub Releases page; `0.8.0` is the new starting line.
- **Login selector polish.** Configured `/login` providers render with distinct success/state styling; the background working indicator uses the braille spinner, and working tips wrap with a hanging indent under the verb.
- **Scratch cleanup on exit.** Sessions that end via an exception now clean up their scratch files instead of orphaning them.
- **Readable diff context.** Unchanged context lines in file-edit diff snippets now render in the normal body-text color instead of muted grey, so edited-file previews are easier to read; added/removed lines are unchanged.
- **Cleaner slash command menu.** The slash command popup now has a blank line separating it from the input row, drops the repetitive `[command]`/`[shell]` tag (keeping the distinguishing `[skill]`/`[flow]` ones), and gains a persistent footer (`Enter to select · ↑/↓ to navigate · Esc to cancel`) set off by its own separator line. When the list scrolls, the footer folds in a `+N more` count instead of silently hiding entries, and the menu height adapts to the terminal.

## 0.42.0 (2026-06-12)

Expand Down
17 changes: 17 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# CLAUDE.md

This repository's agent guidance lives in `AGENTS.md` (the portable, tracked standard injected
into Pythinker sessions via `PYTHINKER_AGENTS_MD`). Claude Code does not read `AGENTS.md`
automatically, so this file imports it — plus the machine-local overlay — to keep a single source
of truth.

Read both, in order:

1. **`AGENTS.md`** — non-negotiable repository rules. Always applies.
2. **`AGENTS.local`** — machine-specific / private local instructions (gitignored). Read it after
`AGENTS.md`. It may add workflow detail (e.g. the code-graph / graphify workflow) but must not
weaken or override the rules in `AGENTS.md`.

@AGENTS.md

@AGENTS.local
10 changes: 8 additions & 2 deletions docs/en/reference/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,14 @@ warning), and currently has no monitoring visibility.

It:

1. Emits an OTel `error` event with `{site, exc_class, tool, **attrs}`.
2. Calls `sentry.capture_exception(exc)`.
1. Emits an OTel `error` event with `{site, exc_class, expected, tool, **attrs}`.
2. Calls `sentry.capture_exception(exc)` **only when the error is not expected**.
Expected user-environment failures — bad/expired credentials, exhausted
quotas, rate limits, request timeouts, offline network, abandoned OAuth
flows, MCP servers lacking an optional capability (see
`errors.is_expected_error`) — still flow to the OTel `error` stream with
`expected=True`, but are withheld from Sentry/Bugsink, which is reserved for
actionable defects.

Both calls are wrapped in `contextlib.suppress(Exception)` so monitoring can
never break the host program.
Expand Down
20 changes: 20 additions & 0 deletions src/pythinker_code/telemetry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,26 @@ def flush_sync() -> None:
"""
if _sink is not None:
_sink.flush_sync()
elif _event_queue:
# No sink was ever attached — e.g. a crash during startup, before
# attach_sink() runs. Best-effort direct emit so the crash/error event
# still reaches SigNoz (otel.emit_log no-ops if OTel was never inited).
try:
from pythinker_code.telemetry.sink import emit_events_to_otel

for event in _event_queue:
if event.get("device_id") is None:
event["device_id"] = _device_id
if event.get("session_id") is None:
event["session_id"] = _session_id
emit_events_to_otel(list(_event_queue))
# Only drop the buffer once the events have been handed off, so a
# failed emit leaves them intact for the vendor-SDK flush below.
_event_queue.clear()
except Exception as exc:
from pythinker_code.utils.logging import logger

logger.debug("Crash-safe telemetry flush failed: {err}", err=exc)
# Flush vendor SDKs last — they take the network hit.
try:
from pythinker_code.telemetry import otel as _otel
Expand Down
111 changes: 88 additions & 23 deletions src/pythinker_code/telemetry/sink.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,93 @@ def _flatten_event(event: dict[str, Any]) -> dict[str, Any]:
return out


# Event names whose telemetry must be recorded at ERROR severity, so SigNoz
# severity filters and error dashboards can find them. Without this, track()
# forwards everything at the emit_log() default of INFO, leaving crashes and
# handled errors indistinguishable from product-analytics events.
_ERROR_EVENTS = frozenset({"error", "crash", "api_error"})

# Telemetry event name -> OTel severity. Names not listed stay INFO.
_EVENT_SEVERITY: dict[str, str] = {
**dict.fromkeys(_ERROR_EVENTS, "error"),
# A session that failed to load but fell back to a fresh state is degraded,
# not broken — surface it above INFO without crying ERROR.
"session_load_failed": "warning",
}


def _event_severity(event_name: str) -> str:
"""Map a telemetry event name to an OTel severity (defaults to ``info``)."""
return _EVENT_SEVERITY.get(event_name, "info")


def _apply_canonical_error_attrs(event_name: str, attrs: dict[str, Any]) -> None:
"""Add stable, queryable ``error.*`` attributes for error-like events.

Call sites are inconsistent: crashes and API errors carry ``error_type``
while handled errors carry ``exc_class`` — which, after flattening, become
``property.error_type`` / ``property.exc_class``. Dashboards shouldn't have
to know which. Mirror the discriminator into canonical top-level keys while
leaving the original ``property.*`` values untouched. Mutates ``attrs``.
"""
if event_name not in _ERROR_EVENTS:
return
error_type = attrs.get("property.error_type") or attrs.get("property.exc_class")
if error_type is not None:
attrs.setdefault("error.type", error_type)
site = attrs.get("property.site")
if site is not None:
attrs.setdefault("error.site", site)
if "property.expected" in attrs:
attrs.setdefault("error.expected", attrs["property.expected"])
# 'error' (handled) vs 'crash' (uncaught) vs 'api_error' (provider call).
attrs.setdefault("error.kind", event_name)


def emit_events_to_otel(events: list[dict[str, Any]]) -> None:
"""Forward telemetry events to the OTel logs pipeline.

Shared by :meth:`EventSink._emit_to_otel` and the crash-safe queue drain in
:func:`pythinker_code.telemetry.flush_sync`, so a startup crash that occurs
before any sink is attached still reaches SigNoz. ``otel.emit_log`` is a
no-op when the SDK was never initialized, so this is always safe to call.
"""
if not events:
return
try:
from pythinker_code.telemetry import otel as _otel
except Exception as exc:
logger.debug(
"Telemetry OTel import failed; dropping {n} events: {err}",
n=len(events),
err=exc,
)
return
Comment thread
coderabbitai[bot] marked this conversation as resolved.

for event in events:
event_name = str(event.get("event") or "event")
ts = event.get("timestamp")
ts_ns = int(ts * 1_000_000_000) if isinstance(ts, (int, float)) else None
try:
attrs = _flatten_event(event)
except TypeError as exc:
# Schema violation — drop, never retry.
logger.debug("Telemetry event dropped (non-primitive attr): {err}", err=exc)
continue
attrs.pop("event", None)
attrs.pop("timestamp", None)
_apply_canonical_error_attrs(event_name, attrs)
try:
_otel.emit_log(
name=event_name,
attributes=attrs,
severity=_event_severity(event_name),
timestamp_ns=ts_ns,
)
except Exception:
logger.debug("OTel emit failed; event dropped")


class EventSink:
"""Buffers telemetry events and flushes them in batches to OTel logs."""

Expand Down Expand Up @@ -155,29 +242,7 @@ async def _flush_async(self) -> None:
self._emit_to_otel(events)

def _emit_to_otel(self, events: list[dict[str, Any]]) -> None:
if not events:
return
try:
from pythinker_code.telemetry import otel as _otel

for event in events:
ts = event.get("timestamp")
ts_ns = int(ts * 1_000_000_000) if isinstance(ts, (int, float)) else None
try:
attrs = _flatten_event(event)
except TypeError as exc:
# Schema violation — drop, never retry.
logger.debug("Telemetry event dropped (non-primitive attr): {err}", err=exc)
continue
attrs.pop("event", None)
attrs.pop("timestamp", None)
_otel.emit_log(
name=str(event.get("event") or "event"),
attributes=attrs,
timestamp_ns=ts_ns,
)
except Exception:
logger.debug("OTel flush failed; events dropped")
emit_events_to_otel(events)

def _schedule_async_flush(self) -> None:
"""Schedule an async flush from any thread."""
Expand Down
Loading
Loading