ams-observability 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ name: Publish to PyPI
2
+
3
+ # Publishes ams-observability to PyPI when a GitHub Release is published.
4
+ # Uses PyPI Trusted Publishing (OIDC) — no API token is stored anywhere.
5
+ # One-time setup on PyPI: add a trusted publisher for project
6
+ # `ams-observability` (owner `mathu97`, repo `ams`, workflow `release.yml`).
7
+ #
8
+ # To cut a release: bump `version` in pyproject.toml, then create a GitHub
9
+ # Release (tag like v0.1.0). Publishing runs automatically.
10
+
11
+ on:
12
+ release:
13
+ types: [published]
14
+
15
+ jobs:
16
+ publish:
17
+ runs-on: ubuntu-latest
18
+ permissions:
19
+ id-token: write # required for trusted publishing
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: astral-sh/setup-uv@v5
23
+ - name: Build sdist and wheel
24
+ run: uv build
25
+ - name: Publish to PyPI
26
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .env
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ ams-data/
13
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mathu97
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.4
2
+ Name: ams-observability
3
+ Version: 0.1.0
4
+ Summary: A super simple monitoring system for Claude agents — full session traces as JSON in blob storage.
5
+ Project-URL: Homepage, https://github.com/mathu97/ams
6
+ Project-URL: Repository, https://github.com/mathu97/ams
7
+ Author: mathu97
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: agents,claude,llm,monitoring,observability,tracing
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: System :: Monitoring
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: boto3>=1.28
19
+ Requires-Dist: pydantic>=2.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
22
+ Requires-Dist: pytest>=8.0; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # AMS — Agent Monitoring System
26
+
27
+ A **super simple** monitoring system for [Claude agents](https://docs.claude.com/en/api/agent-sdk/overview). Capture a whole Claude Agent SDK session end to end — every tool call, every subagent and *why* it was invoked, the model's reasoning, results, timing, and cost — as **one readable JSON object** in blob storage.
28
+
29
+ No collector, no database, no agent. One JSON file per session in S3-compatible storage. Built to be trivially easy to read and filter (the things that make Arize and friends painful).
30
+
31
+ ```python
32
+ from ams.claude import traced_query
33
+
34
+ async for message in traced_query(prompt="Cancel my membership", options=options):
35
+ ...
36
+ # session written to storage automatically when the stream ends
37
+ ```
38
+
39
+ That's the whole integration. Swap `query` for `traced_query`.
40
+
41
+ ## Why
42
+
43
+ We monitor our Claude agents with Arize today, but it's hard to read, and hard to search/filter for a single session. AMS keeps the data model deliberately flat and typed so a session is obvious to a human and easy to query by a machine. Field names follow the [OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) (`gen_ai.*`) where there's a natural equivalent, so the data can later be re-emitted as OTLP without renaming.
44
+
45
+ ## What it captures
46
+
47
+ A session is one trace of ordered **events**:
48
+
49
+ | Event | Source | Detail captured |
50
+ |---|---|---|
51
+ | `user_prompt` | `UserPromptSubmit` hook | the prompt |
52
+ | `llm_message` | message stream | model, **thinking / chain-of-thought**, assistant text, token usage |
53
+ | `tool_call` | `PreToolUse` + `PostToolUse` / `PostToolUseFailure` hooks | tool name, input, result, error, **timing** |
54
+ | `subagent` | `SubagentStart` / `SubagentStop` hooks | agent type, **why it was invoked** (the prompt), transcript path; child tool calls nest underneath |
55
+ | `notification` | `Notification` hook | message |
56
+
57
+ Plus session **totals**: token usage (incl. cache read/write), cost (USD), turn count, tool/subagent/error counts, and wall-clock + API duration.
58
+
59
+ The Claude Agent SDK has **no built-in OpenTelemetry** — AMS captures everything through hooks and the message stream, which together are the only place this data lives.
60
+
61
+ ## Install
62
+
63
+ ```bash
64
+ pip install ams-observability # published name; you import it as `ams`
65
+ ```
66
+
67
+ Or from a local checkout: `pip install -e .`. S3-compatible storage (boto3) is included by default.
68
+
69
+ Requires Python 3.10+ and the [`claude-agent-sdk`](https://pypi.org/project/claude-agent-sdk/) in your project.
70
+
71
+ ## Configure storage
72
+
73
+ S3-compatible storage is the default. Works against **AWS S3, Cloudflare R2, MinIO** — anything speaking the S3 API.
74
+
75
+ ```bash
76
+ export AMS_S3_BUCKET=my-agent-traces
77
+ export AMS_S3_PREFIX=ams # optional, default "ams"
78
+ export AMS_S3_ENDPOINT_URL=https://<account>.r2.cloudflarestorage.com # omit for AWS S3
79
+ export AMS_S3_REGION=auto
80
+ # credentials via the standard AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
81
+ ```
82
+
83
+ Or write to local disk for development:
84
+
85
+ ```bash
86
+ export AMS_STORAGE=local
87
+ export AMS_LOCAL_DIR=./ams-data # optional
88
+ ```
89
+
90
+ ### Layout in the bucket
91
+
92
+ ```
93
+ {prefix}/sessions/{YYYY}/{MM}/{DD}/{session_id}.json full session
94
+ {prefix}/index/{session_id}.json compact summary (for listing/filtering)
95
+ ```
96
+
97
+ The small `index/` objects let a frontend build a searchable session list without opening every full session.
98
+
99
+ ## Usage
100
+
101
+ ### One-call (drop-in for `query`)
102
+
103
+ ```python
104
+ from ams import Agent
105
+ from ams.claude import traced_query
106
+
107
+ async for message in traced_query(
108
+ prompt="...",
109
+ options=options, # your ClaudeAgentOptions
110
+ agent=Agent(name="support-bot", version="2026.06"),
111
+ environment="prod",
112
+ tags=["voice", "cancellation"],
113
+ metadata={"team_id": "t_42"},
114
+ ):
115
+ print(message)
116
+ ```
117
+
118
+ ### With `ClaudeSDKClient`
119
+
120
+ Merge AMS hooks into your options, feed messages to the tracer, and `finish()` when done:
121
+
122
+ ```python
123
+ from ams import Tracer
124
+ from ams.claude import instrument_options
125
+
126
+ tracer = Tracer(environment="prod", tags=["chat"])
127
+ options = instrument_options(my_options, tracer)
128
+
129
+ async with ClaudeSDKClient(options=options) as client:
130
+ await client.query("...")
131
+ async for message in client.receive_response():
132
+ tracer.record_message(message)
133
+
134
+ session = tracer.finish()
135
+ ```
136
+
137
+ ### Custom storage
138
+
139
+ Pass any object with `put_session(session) -> str`:
140
+
141
+ ```python
142
+ tracer = Tracer(storage=MyStorage())
143
+ ```
144
+
145
+ ## Options
146
+
147
+ | Tracer arg / env | Default | Notes |
148
+ |---|---|---|
149
+ | `storage` / `AMS_STORAGE` | S3 | `local` to write to disk |
150
+ | `agent` | — | `Agent(name=..., version=...)` |
151
+ | `environment` | — | e.g. `prod`, `staging` |
152
+ | `tags`, `metadata` | — | free-form, promoted into the index for filtering |
153
+ | `capture_thinking` | `True` | record the model's reasoning blocks |
154
+ | `redact` / `AMS_REDACT` | `False` | opt-in PII redaction (email / phone / card / SSN) |
155
+
156
+ AMS never throws into your agent: hook and storage failures are logged, not raised.
157
+
158
+ ## How it works
159
+
160
+ See [`docs/architecture.md`](docs/architecture.md) for the module map and the two-channel design (hooks + message stream) that AMS fuses into one session.
161
+
162
+ ## Schema
163
+
164
+ See [`docs/schema.md`](docs/schema.md) for the full session JSON schema with an example. The contract lives in one file: [`ams/schema.py`](ams/schema.py).
165
+
166
+ ## Frontend
167
+
168
+ A simple frontend to browse and filter sessions is planned (not built yet). See [`docs/frontend-notes.md`](docs/frontend-notes.md) for the intended design — it reads the `index/` summaries to list sessions and fetches a full session JSON on click.
169
+
170
+ ## Development
171
+
172
+ ```bash
173
+ python -m venv .venv && . .venv/bin/activate
174
+ pip install -e ".[dev]"
175
+ pytest
176
+ ```
177
+
178
+ ## License
179
+
180
+ MIT
@@ -0,0 +1,156 @@
1
+ # AMS — Agent Monitoring System
2
+
3
+ A **super simple** monitoring system for [Claude agents](https://docs.claude.com/en/api/agent-sdk/overview). Capture a whole Claude Agent SDK session end to end — every tool call, every subagent and *why* it was invoked, the model's reasoning, results, timing, and cost — as **one readable JSON object** in blob storage.
4
+
5
+ No collector, no database, no agent. One JSON file per session in S3-compatible storage. Built to be trivially easy to read and filter (the things that make Arize and friends painful).
6
+
7
+ ```python
8
+ from ams.claude import traced_query
9
+
10
+ async for message in traced_query(prompt="Cancel my membership", options=options):
11
+ ...
12
+ # session written to storage automatically when the stream ends
13
+ ```
14
+
15
+ That's the whole integration. Swap `query` for `traced_query`.
16
+
17
+ ## Why
18
+
19
+ We monitor our Claude agents with Arize today, but it's hard to read, and hard to search/filter for a single session. AMS keeps the data model deliberately flat and typed so a session is obvious to a human and easy to query by a machine. Field names follow the [OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) (`gen_ai.*`) where there's a natural equivalent, so the data can later be re-emitted as OTLP without renaming.
20
+
21
+ ## What it captures
22
+
23
+ A session is one trace of ordered **events**:
24
+
25
+ | Event | Source | Detail captured |
26
+ |---|---|---|
27
+ | `user_prompt` | `UserPromptSubmit` hook | the prompt |
28
+ | `llm_message` | message stream | model, **thinking / chain-of-thought**, assistant text, token usage |
29
+ | `tool_call` | `PreToolUse` + `PostToolUse` / `PostToolUseFailure` hooks | tool name, input, result, error, **timing** |
30
+ | `subagent` | `SubagentStart` / `SubagentStop` hooks | agent type, **why it was invoked** (the prompt), transcript path; child tool calls nest underneath |
31
+ | `notification` | `Notification` hook | message |
32
+
33
+ Plus session **totals**: token usage (incl. cache read/write), cost (USD), turn count, tool/subagent/error counts, and wall-clock + API duration.
34
+
35
+ The Claude Agent SDK has **no built-in OpenTelemetry** — AMS captures everything through hooks and the message stream, which together are the only place this data lives.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install ams-observability # published name; you import it as `ams`
41
+ ```
42
+
43
+ Or from a local checkout: `pip install -e .`. S3-compatible storage (boto3) is included by default.
44
+
45
+ Requires Python 3.10+ and the [`claude-agent-sdk`](https://pypi.org/project/claude-agent-sdk/) in your project.
46
+
47
+ ## Configure storage
48
+
49
+ S3-compatible storage is the default. Works against **AWS S3, Cloudflare R2, MinIO** — anything speaking the S3 API.
50
+
51
+ ```bash
52
+ export AMS_S3_BUCKET=my-agent-traces
53
+ export AMS_S3_PREFIX=ams # optional, default "ams"
54
+ export AMS_S3_ENDPOINT_URL=https://<account>.r2.cloudflarestorage.com # omit for AWS S3
55
+ export AMS_S3_REGION=auto
56
+ # credentials via the standard AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
57
+ ```
58
+
59
+ Or write to local disk for development:
60
+
61
+ ```bash
62
+ export AMS_STORAGE=local
63
+ export AMS_LOCAL_DIR=./ams-data # optional
64
+ ```
65
+
66
+ ### Layout in the bucket
67
+
68
+ ```
69
+ {prefix}/sessions/{YYYY}/{MM}/{DD}/{session_id}.json full session
70
+ {prefix}/index/{session_id}.json compact summary (for listing/filtering)
71
+ ```
72
+
73
+ The small `index/` objects let a frontend build a searchable session list without opening every full session.
74
+
75
+ ## Usage
76
+
77
+ ### One-call (drop-in for `query`)
78
+
79
+ ```python
80
+ from ams import Agent
81
+ from ams.claude import traced_query
82
+
83
+ async for message in traced_query(
84
+ prompt="...",
85
+ options=options, # your ClaudeAgentOptions
86
+ agent=Agent(name="support-bot", version="2026.06"),
87
+ environment="prod",
88
+ tags=["voice", "cancellation"],
89
+ metadata={"team_id": "t_42"},
90
+ ):
91
+ print(message)
92
+ ```
93
+
94
+ ### With `ClaudeSDKClient`
95
+
96
+ Merge AMS hooks into your options, feed messages to the tracer, and `finish()` when done:
97
+
98
+ ```python
99
+ from ams import Tracer
100
+ from ams.claude import instrument_options
101
+
102
+ tracer = Tracer(environment="prod", tags=["chat"])
103
+ options = instrument_options(my_options, tracer)
104
+
105
+ async with ClaudeSDKClient(options=options) as client:
106
+ await client.query("...")
107
+ async for message in client.receive_response():
108
+ tracer.record_message(message)
109
+
110
+ session = tracer.finish()
111
+ ```
112
+
113
+ ### Custom storage
114
+
115
+ Pass any object with `put_session(session) -> str`:
116
+
117
+ ```python
118
+ tracer = Tracer(storage=MyStorage())
119
+ ```
120
+
121
+ ## Options
122
+
123
+ | Tracer arg / env | Default | Notes |
124
+ |---|---|---|
125
+ | `storage` / `AMS_STORAGE` | S3 | `local` to write to disk |
126
+ | `agent` | — | `Agent(name=..., version=...)` |
127
+ | `environment` | — | e.g. `prod`, `staging` |
128
+ | `tags`, `metadata` | — | free-form, promoted into the index for filtering |
129
+ | `capture_thinking` | `True` | record the model's reasoning blocks |
130
+ | `redact` / `AMS_REDACT` | `False` | opt-in PII redaction (email / phone / card / SSN) |
131
+
132
+ AMS never throws into your agent: hook and storage failures are logged, not raised.
133
+
134
+ ## How it works
135
+
136
+ See [`docs/architecture.md`](docs/architecture.md) for the module map and the two-channel design (hooks + message stream) that AMS fuses into one session.
137
+
138
+ ## Schema
139
+
140
+ See [`docs/schema.md`](docs/schema.md) for the full session JSON schema with an example. The contract lives in one file: [`ams/schema.py`](ams/schema.py).
141
+
142
+ ## Frontend
143
+
144
+ A simple frontend to browse and filter sessions is planned (not built yet). See [`docs/frontend-notes.md`](docs/frontend-notes.md) for the intended design — it reads the `index/` summaries to list sessions and fetches a full session JSON on click.
145
+
146
+ ## Development
147
+
148
+ ```bash
149
+ python -m venv .venv && . .venv/bin/activate
150
+ pip install -e ".[dev]"
151
+ pytest
152
+ ```
153
+
154
+ ## License
155
+
156
+ MIT
@@ -0,0 +1,38 @@
1
+ """AMS — a super simple monitoring system for Claude agents.
2
+
3
+ Capture a whole Claude Agent SDK session end to end — every tool call, every
4
+ subagent and why it was invoked, the model's reasoning, results, timing and
5
+ cost — as one readable JSON object in blob storage.
6
+
7
+ from ams.claude import traced_query
8
+
9
+ async for message in traced_query(prompt="...", options=options):
10
+ ...
11
+ """
12
+
13
+ from .schema import (
14
+ SCHEMA_VERSION,
15
+ Agent,
16
+ Event,
17
+ EventType,
18
+ Session,
19
+ Status,
20
+ Totals,
21
+ Usage,
22
+ )
23
+ from .tracer import Tracer
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ __all__ = [
28
+ "Tracer",
29
+ "Session",
30
+ "Event",
31
+ "EventType",
32
+ "Status",
33
+ "Totals",
34
+ "Usage",
35
+ "Agent",
36
+ "SCHEMA_VERSION",
37
+ "__version__",
38
+ ]
@@ -0,0 +1,46 @@
1
+ """Glue between AMS and `claude_agent_sdk`. This is the whole integration
2
+ surface: swap `query` for `traced_query`, or merge `tracer.hooks()` into your
3
+ options if you drive a ClaudeSDKClient yourself."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, AsyncIterator, Optional
8
+
9
+ from .tracer import Tracer
10
+
11
+
12
+ def instrument_options(options: Any, tracer: Tracer) -> Any:
13
+ """Merge AMS hooks into an existing ClaudeAgentOptions, keeping any of yours."""
14
+ merged = dict(getattr(options, "hooks", None) or {})
15
+ for name, matchers in tracer.hooks().items():
16
+ merged[name] = list(merged.get(name, [])) + list(matchers)
17
+ options.hooks = merged
18
+ return options
19
+
20
+
21
+ async def traced_query(
22
+ *,
23
+ prompt: Any,
24
+ options: Any = None,
25
+ tracer: Optional[Tracer] = None,
26
+ **tracer_kwargs: Any,
27
+ ) -> AsyncIterator[Any]:
28
+ """Drop-in replacement for `claude_agent_sdk.query` that records the session.
29
+
30
+ from ams.claude import traced_query
31
+
32
+ async for message in traced_query(prompt="...", options=options):
33
+ print(message)
34
+
35
+ Extra keyword args (storage, agent, environment, tags, metadata, redact)
36
+ are forwarded to `Tracer`. On stream completion the session is written to
37
+ storage automatically.
38
+ """
39
+ from claude_agent_sdk import ClaudeAgentOptions, query
40
+
41
+ tracer = tracer or Tracer(**tracer_kwargs)
42
+ options = options or ClaudeAgentOptions()
43
+ options = instrument_options(options, tracer)
44
+
45
+ async for message in tracer.watch(query(prompt=prompt, options=options)):
46
+ yield message
@@ -0,0 +1,45 @@
1
+ """Token -> USD cost. The Claude Agent SDK already reports `total_cost_usd` on
2
+ the result message, so AMS uses that for the session total. This table is a
3
+ fallback for costing an individual LLM call from its token usage.
4
+
5
+ Prices are USD per million tokens. Update as needed; matching is by substring so
6
+ dated model ids (e.g. `claude-opus-4-8-20260101`) resolve to the right family.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Optional
12
+
13
+ from .schema import Usage
14
+
15
+ # (input, output, cache_write, cache_read) per million tokens
16
+ _PRICES: dict[str, tuple[float, float, float, float]] = {
17
+ "claude-opus-4": (15.0, 75.0, 18.75, 1.5),
18
+ "claude-sonnet-4": (3.0, 15.0, 3.75, 0.3),
19
+ "claude-haiku-4": (1.0, 5.0, 1.25, 0.1),
20
+ "claude-3-5-haiku": (0.8, 4.0, 1.0, 0.08),
21
+ }
22
+
23
+
24
+ def _match(model: str) -> Optional[tuple[float, float, float, float]]:
25
+ model = model.lower()
26
+ for key, price in _PRICES.items():
27
+ if key in model:
28
+ return price
29
+ return None
30
+
31
+
32
+ def cost_usd(model: Optional[str], usage: Optional[Usage]) -> Optional[float]:
33
+ if not model or usage is None:
34
+ return None
35
+ price = _match(model)
36
+ if price is None:
37
+ return None
38
+ p_in, p_out, p_cw, p_cr = price
39
+ total = (
40
+ usage.input_tokens * p_in
41
+ + usage.output_tokens * p_out
42
+ + usage.cache_creation_input_tokens * p_cw
43
+ + usage.cache_read_input_tokens * p_cr
44
+ )
45
+ return round(total / 1_000_000, 6)
@@ -0,0 +1,31 @@
1
+ """Optional PII redaction. Off by default — AMS captures full detail unless you
2
+ opt in. Turn it on with `Tracer(redact=True)` or `AMS_REDACT=1` when sessions
3
+ may contain sensitive caller data (phone numbers, emails, cards)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from typing import Any
9
+
10
+ _PATTERNS = [
11
+ (re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"), "[email]"),
12
+ (re.compile(r"\+?\d[\d\s().-]{7,}\d"), "[phone]"),
13
+ (re.compile(r"\b(?:\d[ -]*?){13,16}\b"), "[card]"),
14
+ (re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), "[ssn]"),
15
+ ]
16
+
17
+
18
+ def redact_text(text: str) -> str:
19
+ for pattern, replacement in _PATTERNS:
20
+ text = pattern.sub(replacement, text)
21
+ return text
22
+
23
+
24
+ def redact(value: Any) -> Any:
25
+ if isinstance(value, str):
26
+ return redact_text(value)
27
+ if isinstance(value, dict):
28
+ return {k: redact(v) for k, v in value.items()}
29
+ if isinstance(value, list):
30
+ return [redact(v) for v in value]
31
+ return value