strands-token-telemetry 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,53 @@
1
+ # Publish to PyPI when a GitHub Release is published.
2
+ #
3
+ # Setup required (one-time):
4
+ # 1. In this repo's Settings > Environments, create an environment named "pypi".
5
+ # 2. On PyPI, go to your project > Publishing > Add a new publisher:
6
+ # - Owner: flockcover
7
+ # - Repository: strands-token-telemetry
8
+ # - Workflow: publish.yml
9
+ # - Environment: pypi
10
+
11
+ name: Publish to PyPI
12
+
13
+ on:
14
+ release:
15
+ types: [published]
16
+
17
+ jobs:
18
+ test:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+
23
+ - uses: actions/setup-python@v5
24
+ with:
25
+ python-version: "3.12"
26
+
27
+ - name: Install dependencies
28
+ run: pip install -e ".[dev]"
29
+
30
+ - name: Run tests
31
+ run: pytest -v
32
+
33
+ publish:
34
+ needs: test
35
+ runs-on: ubuntu-latest
36
+ environment: pypi
37
+ permissions:
38
+ id-token: write
39
+ steps:
40
+ - uses: actions/checkout@v4
41
+
42
+ - uses: actions/setup-python@v5
43
+ with:
44
+ python-version: "3.12"
45
+
46
+ - name: Install build tool
47
+ run: pip install build
48
+
49
+ - name: Build package
50
+ run: python -m build
51
+
52
+ - name: Publish to PyPI
53
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .eggs/
7
+ *.egg
8
+ .venv/
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .claude/
12
+ .devcontainer/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
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,273 @@
1
+ Metadata-Version: 2.4
2
+ Name: strands-token-telemetry
3
+ Version: 0.1.0
4
+ Summary: Emit Strands agent token usage as CloudWatch EMF metrics
5
+ Project-URL: Homepage, https://github.com/flockcover/strands-token-telemetry
6
+ Project-URL: Issues, https://github.com/flockcover/strands-token-telemetry/issues
7
+ Author-email: Tom Harvey <tom.harvey@flockcover.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.10
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest; extra == 'dev'
16
+ Requires-Dist: ruff; extra == 'dev'
17
+ Requires-Dist: strands-agents>=0.1.0; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # strands-token-telemetry
21
+
22
+ Emit [Strands Agents](https://github.com/strands-agents/sdk-python) token usage as [CloudWatch EMF](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format.html) metrics.
23
+
24
+ ## Why this library?
25
+
26
+ Strands Agents has built-in observability via OpenTelemetry traces, and AgentCore adds automatic CloudWatch metrics — but this turnkey telemetry only works when you use **BedrockModel** and deploy to **AgentCore**.
27
+
28
+ If you're already on BedrockModel + AgentCore, you may not need this. Otherwise, this library fills three gaps:
29
+
30
+ 1. **Works with any model provider** — not locked into BedrockModel. Use Anthropic, LiteLLM, Ollama, or any Strands-compatible model and still get token metrics.
31
+
32
+ 2. **Works on any infrastructure** — ECS, Lambda, EC2, on-prem, or AgentCore. Anywhere the CloudWatch agent (or a compatible log sink) runs, you get first-class CloudWatch Metrics.
33
+
34
+ 3. **First-class CloudWatch Metrics, not trace attributes** — Strands' OTEL integration captures token counts as span attributes buried in traces. EMF creates real CloudWatch Metrics you can directly alarm on, dashboard, and apply metric math to (e.g. cost estimation). No OTEL collector setup or ADOT config required — just stdout.
35
+
36
+ | | Strands OTEL traces | AgentCore auto-telemetry | This library |
37
+ |---|---|---|---|
38
+ | Any model provider | Yes | BedrockModel only | Yes |
39
+ | Any deploy target | Yes | AgentCore only | Yes |
40
+ | CloudWatch Metrics | No (span attributes) | Yes | Yes |
41
+ | CloudWatch Alarms | No | Yes | Yes |
42
+ | OTEL collector needed | Yes | Managed | No |
43
+ | Setup | Configure ADOT/exporter | Deploy to AgentCore | One hook, one line |
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install strands-token-telemetry
49
+ ```
50
+
51
+ `strands-agents` is a peer dependency — install it separately (or it's likely already in your project).
52
+
53
+ ## AI-assisted integration
54
+
55
+ ### For developers
56
+
57
+ Copy and paste this prompt into Claude Code, Cursor, or your preferred AI coding
58
+ assistant:
59
+
60
+ > Install strands-token-telemetry and add a TokenUsageHook to my Strands agent.
61
+ > Read the integration guide at
62
+ > https://raw.githubusercontent.com/flockcover/strands-token-telemetry/main/docs/llms.txt
63
+ > before writing any code.
64
+
65
+ ### For AI coding assistants
66
+
67
+ If you are an AI coding assistant helping a developer integrate this library,
68
+ fetch the full API reference and integration patterns from the LLM-optimized
69
+ docs before generating code:
70
+
71
+ https://raw.githubusercontent.com/flockcover/strands-token-telemetry/main/docs/llms.txt
72
+
73
+ ## Quick start
74
+
75
+ ```python
76
+ from strands import Agent
77
+ from strands_token_telemetry import TokenUsageHook
78
+
79
+ agent = Agent(hooks=[TokenUsageHook()])
80
+ ```
81
+
82
+ Every agent invocation prints a JSON line to stdout in [CloudWatch EMF](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format.html) format. The CloudWatch agent picks this up and publishes metrics automatically.
83
+
84
+ ### Adding session context
85
+
86
+ A common pattern is tagging metrics with a custom namespace plus user and session
87
+ identifiers so you can filter and query them in CloudWatch Insights. Pass static
88
+ dimension values for the model, and `extra_properties` for fields that should be
89
+ searchable but not published as metric dimensions:
90
+
91
+ ```python
92
+ from strands import Agent
93
+ from strands_token_telemetry import TokenUsageHook
94
+
95
+ hook = TokenUsageHook(
96
+ namespace="AcmeInc/StrandsTokens",
97
+ dimension_values={"Model": model_id},
98
+ extra_properties={"UserId": user_id, "SessionId": session_id},
99
+ )
100
+ agent = Agent(hooks=[hook])
101
+ ```
102
+
103
+ `Model` appears as a CloudWatch Metric dimension you can alarm on, while `UserId`
104
+ and `SessionId` stay as top-level properties queryable with CloudWatch Insights
105
+ (e.g. `filter SessionId = "abc-123"`).
106
+
107
+ Each invocation emits a single JSON line like this (pretty-printed here for
108
+ readability):
109
+
110
+ ```json
111
+ {
112
+ "_aws": {
113
+ "Timestamp": 1700000000000,
114
+ "CloudWatchMetrics": [
115
+ {
116
+ "Namespace": "AcmeInc/StrandsTokens",
117
+ "Dimensions": [["Model"]],
118
+ "Metrics": [
119
+ { "Name": "inputTokens", "Unit": "Count" },
120
+ { "Name": "outputTokens", "Unit": "Count" },
121
+ { "Name": "totalTokens", "Unit": "Count" },
122
+ { "Name": "cacheReadInputTokens", "Unit": "Count" },
123
+ { "Name": "cacheWriteInputTokens", "Unit": "Count" }
124
+ ]
125
+ }
126
+ ]
127
+ },
128
+ "Model": "us.anthropic.claude-sonnet-4-20250514",
129
+ "UserId": "user-42",
130
+ "SessionId": "abc-123",
131
+ "inputTokens": 1024,
132
+ "outputTokens": 256,
133
+ "totalTokens": 1280,
134
+ "cacheReadInputTokens": 512,
135
+ "cacheWriteInputTokens": 0
136
+ }
137
+ ```
138
+
139
+ ## Configuration
140
+
141
+ All constructor parameters are keyword-only.
142
+
143
+ | Parameter | Type | Default | Description |
144
+ |---|---|---|---|
145
+ | `namespace` | `str` | `"Strands/AgentTokenUsage"` | CloudWatch metrics namespace |
146
+ | `dimensions` | `list[list[str]]` | `[["Model"]]` | Dimension key sets |
147
+ | `dimension_values` | `dict[str, str]` | `{}` | Static dimension key/value pairs |
148
+ | `dimension_resolver` | `Callable` | `None` | Receives `AfterInvocationEvent`, returns dynamic dimension values |
149
+ | `extra_properties` | `dict[str, Any]` | `None` | Extra top-level properties (searchable in CloudWatch Insights) |
150
+ | `emitter` | `Callable` | `default_emitter` | Function that receives the payload dict |
151
+
152
+ ## Dynamic dimensions
153
+
154
+ Use `dimension_resolver` when a dimension value isn't known until the agent runs — for example, the model name returned by the provider, or an agent identifier pulled from the event. Static values like environment or service name can go in `dimension_values`; the resolver handles everything that changes per invocation.
155
+
156
+ ```python
157
+ def resolve_dims(event):
158
+ model = getattr(event.result, "model", "unknown") if event.result else "unknown"
159
+ return {"Model": model}
160
+
161
+ agent = Agent(hooks=[
162
+ TokenUsageHook(
163
+ dimensions=[["Model", "Environment"]],
164
+ dimension_values={"Environment": "prod"},
165
+ dimension_resolver=resolve_dims,
166
+ )
167
+ ])
168
+ ```
169
+
170
+ A more advanced example — splitting metrics by both model and a per-request agent name:
171
+
172
+ ```python
173
+ def resolve_dims(event):
174
+ model = getattr(event.result, "model", "unknown") if event.result else "unknown"
175
+ agent_name = getattr(event.result, "name", "default") if event.result else "default"
176
+ return {"Model": model, "AgentName": agent_name}
177
+
178
+ agent = Agent(hooks=[
179
+ TokenUsageHook(
180
+ dimensions=[["Model", "AgentName"]],
181
+ dimension_resolver=resolve_dims,
182
+ )
183
+ ])
184
+ ```
185
+
186
+ ## Custom emitter
187
+
188
+ By default the hook prints compact JSON to stdout, which the CloudWatch agent picks up. Replace the emitter when you need the payload to go somewhere else — for example, sending metrics to a non-CloudWatch backend or routing through your application's structured logging pipeline.
189
+
190
+ ```python
191
+ import json
192
+ import logging
193
+
194
+ logger = logging.getLogger("token_metrics")
195
+
196
+ def log_emitter(payload):
197
+ logger.info(json.dumps(payload))
198
+
199
+ agent = Agent(hooks=[TokenUsageHook(emitter=log_emitter)])
200
+ ```
201
+
202
+ You can also forward to an external service:
203
+
204
+ ```python
205
+ import json
206
+ import urllib.request
207
+
208
+ def webhook_emitter(payload):
209
+ req = urllib.request.Request(
210
+ "https://metrics.example.com/ingest",
211
+ data=json.dumps(payload).encode(),
212
+ headers={"Content-Type": "application/json"},
213
+ )
214
+ urllib.request.urlopen(req)
215
+
216
+ agent = Agent(hooks=[TokenUsageHook(emitter=webhook_emitter)])
217
+ ```
218
+
219
+ ## Local development
220
+
221
+ When you run an agent locally the default emitter prints one compact JSON line to
222
+ stdout on every invocation. For example:
223
+
224
+ ```
225
+ {"_aws":{"Timestamp":1700000000000,"CloudWatchMetrics":[...]},"inputTokens":42,...}
226
+ ```
227
+
228
+ This is normal — it is CloudWatch Embedded Metric Format (EMF) output that the
229
+ CloudWatch agent would consume in production. Locally there is no CloudWatch
230
+ agent, so the lines simply appear in your console.
231
+
232
+ ### Suppressing output
233
+
234
+ Pass a no-op emitter to silence the JSON lines entirely:
235
+
236
+ ```python
237
+ from strands_token_telemetry import TokenUsageHook
238
+
239
+ hook = TokenUsageHook(emitter=lambda payload: None)
240
+ ```
241
+
242
+ ### Human-readable output
243
+
244
+ Pretty-print the payload so you can inspect it during development:
245
+
246
+ ```python
247
+ import json
248
+ from strands_token_telemetry import TokenUsageHook
249
+
250
+ hook = TokenUsageHook(emitter=lambda p: print(json.dumps(p, indent=2)))
251
+ ```
252
+
253
+ ### Logging instead of stdout
254
+
255
+ Route output through Python's `logging` module so it respects your existing log
256
+ configuration:
257
+
258
+ ```python
259
+ import json
260
+ import logging
261
+ from strands_token_telemetry import TokenUsageHook
262
+
263
+ log = logging.getLogger("token_telemetry")
264
+
265
+ hook = TokenUsageHook(emitter=lambda p: log.debug("%s", json.dumps(p)))
266
+ ```
267
+
268
+ ## Development
269
+
270
+ ```bash
271
+ pip install -e ".[dev]"
272
+ pytest -v
273
+ ```
@@ -0,0 +1,254 @@
1
+ # strands-token-telemetry
2
+
3
+ Emit [Strands Agents](https://github.com/strands-agents/sdk-python) token usage as [CloudWatch EMF](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format.html) metrics.
4
+
5
+ ## Why this library?
6
+
7
+ Strands Agents has built-in observability via OpenTelemetry traces, and AgentCore adds automatic CloudWatch metrics — but this turnkey telemetry only works when you use **BedrockModel** and deploy to **AgentCore**.
8
+
9
+ If you're already on BedrockModel + AgentCore, you may not need this. Otherwise, this library fills three gaps:
10
+
11
+ 1. **Works with any model provider** — not locked into BedrockModel. Use Anthropic, LiteLLM, Ollama, or any Strands-compatible model and still get token metrics.
12
+
13
+ 2. **Works on any infrastructure** — ECS, Lambda, EC2, on-prem, or AgentCore. Anywhere the CloudWatch agent (or a compatible log sink) runs, you get first-class CloudWatch Metrics.
14
+
15
+ 3. **First-class CloudWatch Metrics, not trace attributes** — Strands' OTEL integration captures token counts as span attributes buried in traces. EMF creates real CloudWatch Metrics you can directly alarm on, dashboard, and apply metric math to (e.g. cost estimation). No OTEL collector setup or ADOT config required — just stdout.
16
+
17
+ | | Strands OTEL traces | AgentCore auto-telemetry | This library |
18
+ |---|---|---|---|
19
+ | Any model provider | Yes | BedrockModel only | Yes |
20
+ | Any deploy target | Yes | AgentCore only | Yes |
21
+ | CloudWatch Metrics | No (span attributes) | Yes | Yes |
22
+ | CloudWatch Alarms | No | Yes | Yes |
23
+ | OTEL collector needed | Yes | Managed | No |
24
+ | Setup | Configure ADOT/exporter | Deploy to AgentCore | One hook, one line |
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install strands-token-telemetry
30
+ ```
31
+
32
+ `strands-agents` is a peer dependency — install it separately (or it's likely already in your project).
33
+
34
+ ## AI-assisted integration
35
+
36
+ ### For developers
37
+
38
+ Copy and paste this prompt into Claude Code, Cursor, or your preferred AI coding
39
+ assistant:
40
+
41
+ > Install strands-token-telemetry and add a TokenUsageHook to my Strands agent.
42
+ > Read the integration guide at
43
+ > https://raw.githubusercontent.com/flockcover/strands-token-telemetry/main/docs/llms.txt
44
+ > before writing any code.
45
+
46
+ ### For AI coding assistants
47
+
48
+ If you are an AI coding assistant helping a developer integrate this library,
49
+ fetch the full API reference and integration patterns from the LLM-optimized
50
+ docs before generating code:
51
+
52
+ https://raw.githubusercontent.com/flockcover/strands-token-telemetry/main/docs/llms.txt
53
+
54
+ ## Quick start
55
+
56
+ ```python
57
+ from strands import Agent
58
+ from strands_token_telemetry import TokenUsageHook
59
+
60
+ agent = Agent(hooks=[TokenUsageHook()])
61
+ ```
62
+
63
+ Every agent invocation prints a JSON line to stdout in [CloudWatch EMF](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format.html) format. The CloudWatch agent picks this up and publishes metrics automatically.
64
+
65
+ ### Adding session context
66
+
67
+ A common pattern is tagging metrics with a custom namespace plus user and session
68
+ identifiers so you can filter and query them in CloudWatch Insights. Pass static
69
+ dimension values for the model, and `extra_properties` for fields that should be
70
+ searchable but not published as metric dimensions:
71
+
72
+ ```python
73
+ from strands import Agent
74
+ from strands_token_telemetry import TokenUsageHook
75
+
76
+ hook = TokenUsageHook(
77
+ namespace="AcmeInc/StrandsTokens",
78
+ dimension_values={"Model": model_id},
79
+ extra_properties={"UserId": user_id, "SessionId": session_id},
80
+ )
81
+ agent = Agent(hooks=[hook])
82
+ ```
83
+
84
+ `Model` appears as a CloudWatch Metric dimension you can alarm on, while `UserId`
85
+ and `SessionId` stay as top-level properties queryable with CloudWatch Insights
86
+ (e.g. `filter SessionId = "abc-123"`).
87
+
88
+ Each invocation emits a single JSON line like this (pretty-printed here for
89
+ readability):
90
+
91
+ ```json
92
+ {
93
+ "_aws": {
94
+ "Timestamp": 1700000000000,
95
+ "CloudWatchMetrics": [
96
+ {
97
+ "Namespace": "AcmeInc/StrandsTokens",
98
+ "Dimensions": [["Model"]],
99
+ "Metrics": [
100
+ { "Name": "inputTokens", "Unit": "Count" },
101
+ { "Name": "outputTokens", "Unit": "Count" },
102
+ { "Name": "totalTokens", "Unit": "Count" },
103
+ { "Name": "cacheReadInputTokens", "Unit": "Count" },
104
+ { "Name": "cacheWriteInputTokens", "Unit": "Count" }
105
+ ]
106
+ }
107
+ ]
108
+ },
109
+ "Model": "us.anthropic.claude-sonnet-4-20250514",
110
+ "UserId": "user-42",
111
+ "SessionId": "abc-123",
112
+ "inputTokens": 1024,
113
+ "outputTokens": 256,
114
+ "totalTokens": 1280,
115
+ "cacheReadInputTokens": 512,
116
+ "cacheWriteInputTokens": 0
117
+ }
118
+ ```
119
+
120
+ ## Configuration
121
+
122
+ All constructor parameters are keyword-only.
123
+
124
+ | Parameter | Type | Default | Description |
125
+ |---|---|---|---|
126
+ | `namespace` | `str` | `"Strands/AgentTokenUsage"` | CloudWatch metrics namespace |
127
+ | `dimensions` | `list[list[str]]` | `[["Model"]]` | Dimension key sets |
128
+ | `dimension_values` | `dict[str, str]` | `{}` | Static dimension key/value pairs |
129
+ | `dimension_resolver` | `Callable` | `None` | Receives `AfterInvocationEvent`, returns dynamic dimension values |
130
+ | `extra_properties` | `dict[str, Any]` | `None` | Extra top-level properties (searchable in CloudWatch Insights) |
131
+ | `emitter` | `Callable` | `default_emitter` | Function that receives the payload dict |
132
+
133
+ ## Dynamic dimensions
134
+
135
+ Use `dimension_resolver` when a dimension value isn't known until the agent runs — for example, the model name returned by the provider, or an agent identifier pulled from the event. Static values like environment or service name can go in `dimension_values`; the resolver handles everything that changes per invocation.
136
+
137
+ ```python
138
+ def resolve_dims(event):
139
+ model = getattr(event.result, "model", "unknown") if event.result else "unknown"
140
+ return {"Model": model}
141
+
142
+ agent = Agent(hooks=[
143
+ TokenUsageHook(
144
+ dimensions=[["Model", "Environment"]],
145
+ dimension_values={"Environment": "prod"},
146
+ dimension_resolver=resolve_dims,
147
+ )
148
+ ])
149
+ ```
150
+
151
+ A more advanced example — splitting metrics by both model and a per-request agent name:
152
+
153
+ ```python
154
+ def resolve_dims(event):
155
+ model = getattr(event.result, "model", "unknown") if event.result else "unknown"
156
+ agent_name = getattr(event.result, "name", "default") if event.result else "default"
157
+ return {"Model": model, "AgentName": agent_name}
158
+
159
+ agent = Agent(hooks=[
160
+ TokenUsageHook(
161
+ dimensions=[["Model", "AgentName"]],
162
+ dimension_resolver=resolve_dims,
163
+ )
164
+ ])
165
+ ```
166
+
167
+ ## Custom emitter
168
+
169
+ By default the hook prints compact JSON to stdout, which the CloudWatch agent picks up. Replace the emitter when you need the payload to go somewhere else — for example, sending metrics to a non-CloudWatch backend or routing through your application's structured logging pipeline.
170
+
171
+ ```python
172
+ import json
173
+ import logging
174
+
175
+ logger = logging.getLogger("token_metrics")
176
+
177
+ def log_emitter(payload):
178
+ logger.info(json.dumps(payload))
179
+
180
+ agent = Agent(hooks=[TokenUsageHook(emitter=log_emitter)])
181
+ ```
182
+
183
+ You can also forward to an external service:
184
+
185
+ ```python
186
+ import json
187
+ import urllib.request
188
+
189
+ def webhook_emitter(payload):
190
+ req = urllib.request.Request(
191
+ "https://metrics.example.com/ingest",
192
+ data=json.dumps(payload).encode(),
193
+ headers={"Content-Type": "application/json"},
194
+ )
195
+ urllib.request.urlopen(req)
196
+
197
+ agent = Agent(hooks=[TokenUsageHook(emitter=webhook_emitter)])
198
+ ```
199
+
200
+ ## Local development
201
+
202
+ When you run an agent locally the default emitter prints one compact JSON line to
203
+ stdout on every invocation. For example:
204
+
205
+ ```
206
+ {"_aws":{"Timestamp":1700000000000,"CloudWatchMetrics":[...]},"inputTokens":42,...}
207
+ ```
208
+
209
+ This is normal — it is CloudWatch Embedded Metric Format (EMF) output that the
210
+ CloudWatch agent would consume in production. Locally there is no CloudWatch
211
+ agent, so the lines simply appear in your console.
212
+
213
+ ### Suppressing output
214
+
215
+ Pass a no-op emitter to silence the JSON lines entirely:
216
+
217
+ ```python
218
+ from strands_token_telemetry import TokenUsageHook
219
+
220
+ hook = TokenUsageHook(emitter=lambda payload: None)
221
+ ```
222
+
223
+ ### Human-readable output
224
+
225
+ Pretty-print the payload so you can inspect it during development:
226
+
227
+ ```python
228
+ import json
229
+ from strands_token_telemetry import TokenUsageHook
230
+
231
+ hook = TokenUsageHook(emitter=lambda p: print(json.dumps(p, indent=2)))
232
+ ```
233
+
234
+ ### Logging instead of stdout
235
+
236
+ Route output through Python's `logging` module so it respects your existing log
237
+ configuration:
238
+
239
+ ```python
240
+ import json
241
+ import logging
242
+ from strands_token_telemetry import TokenUsageHook
243
+
244
+ log = logging.getLogger("token_telemetry")
245
+
246
+ hook = TokenUsageHook(emitter=lambda p: log.debug("%s", json.dumps(p)))
247
+ ```
248
+
249
+ ## Development
250
+
251
+ ```bash
252
+ pip install -e ".[dev]"
253
+ pytest -v
254
+ ```
@@ -0,0 +1,125 @@
1
+ # Publishing to PyPI
2
+
3
+ This project is published to PyPI automatically via GitHub Actions using [Trusted Publishing (OIDC)](https://docs.pypi.org/trusted-publishers/). No API tokens or passwords are stored in the repository.
4
+
5
+ ## How it works
6
+
7
+ The workflow at `.github/workflows/publish.yml` runs whenever a GitHub Release is published. It:
8
+
9
+ 1. Runs the test suite (`pytest -v`)
10
+ 2. Builds the package (`python -m build`, producing sdist and wheel)
11
+ 3. Publishes to PyPI using OIDC identity tokens
12
+
13
+ PyPI verifies that the upload request came from an authorized GitHub Actions workflow in this repository, with no shared secrets involved.
14
+
15
+ ## Initial setup
16
+
17
+ ### 1. Create the GitHub environment
18
+
19
+ 1. Go to **Settings > Environments** in the GitHub repository
20
+ 2. Click **New environment**
21
+ 3. Name it `pypi` (must match the `environment:` value in the workflow)
22
+ 4. Optionally add protection rules (e.g. required reviewers) to gate releases
23
+
24
+ ### 2. Register the trusted publisher on PyPI
25
+
26
+ If the project does not yet exist on PyPI, use "pending publisher" to claim the name:
27
+
28
+ 1. Log in to [pypi.org](https://pypi.org)
29
+ 2. Go to your account's [Publishing](https://pypi.org/manage/account/publishing/) page
30
+ 3. Under **Add a new pending publisher**, fill in:
31
+ - **PyPI project name**: `strands-token-telemetry`
32
+ - **Owner**: `flockcover`
33
+ - **Repository**: `strands-token-telemetry`
34
+ - **Workflow name**: `publish.yml`
35
+ - **Environment name**: `pypi`
36
+ 4. Click **Add**
37
+
38
+ If the project already exists on PyPI:
39
+
40
+ 1. Go to the project's [manage page](https://pypi.org/manage/project/strands-token-telemetry/settings/publishing/)
41
+ 2. Under **Add a new publisher**, fill in the same values as above
42
+
43
+ ## Triggering a release
44
+
45
+ ### 1. Bump the version
46
+
47
+ Update the `version` field in `pyproject.toml`:
48
+
49
+ ```toml
50
+ [project]
51
+ version = "0.2.0"
52
+ ```
53
+
54
+ Commit and push to `main`.
55
+
56
+ ### 2. Create a GitHub Release
57
+
58
+ 1. Go to **Releases** in the GitHub repository
59
+ 2. Click **Draft a new release**
60
+ 3. Click **Choose a tag** and type the new version prefixed with `v` (e.g. `v0.2.0`), then select **Create new tag on publish**
61
+ 4. Set the release title (e.g. `v0.2.0`)
62
+ 5. Write release notes (or click **Generate release notes** to auto-fill from merged PRs)
63
+ 6. Click **Publish release**
64
+
65
+ This triggers the workflow. Monitor progress in the **Actions** tab.
66
+
67
+ ### Using the GitHub CLI
68
+
69
+ ```bash
70
+ # create a tag and release in one step
71
+ gh release create v0.2.0 --title "v0.2.0" --generate-notes
72
+ ```
73
+
74
+ ## Maintaining the OIDC connection
75
+
76
+ ### What can break
77
+
78
+ The trusted publisher configuration on PyPI must match the workflow exactly. A publish will fail if any of these change without updating PyPI:
79
+
80
+ | Field | Must match |
81
+ |---|---|
82
+ | Owner | `flockcover` (GitHub org) |
83
+ | Repository | `strands-token-telemetry` |
84
+ | Workflow name | `publish.yml` (filename, not the `name:` field inside it) |
85
+ | Environment name | `pypi` |
86
+
87
+ ### If the workflow file is renamed
88
+
89
+ 1. Rename the file in the repository
90
+ 2. On PyPI, remove the old publisher and add a new one with the updated workflow name
91
+
92
+ ### If the repository is transferred or renamed
93
+
94
+ 1. On PyPI, remove the old publisher
95
+ 2. Add a new publisher with the updated owner/repository values
96
+
97
+ ### If the GitHub environment is renamed
98
+
99
+ 1. Update the `environment:` value in the workflow to match the new name
100
+ 2. On PyPI, remove the old publisher and add a new one with the new environment name
101
+
102
+ ### Verifying the connection
103
+
104
+ After any changes, create a pre-release to test:
105
+
106
+ ```bash
107
+ gh release create v0.2.0-rc.1 --title "v0.2.0-rc.1" --prerelease --generate-notes
108
+ ```
109
+
110
+ Check the Actions tab for the workflow run. If the publish step fails with a "trusted publisher not found" error, compare the values in the error message against your PyPI publisher configuration.
111
+
112
+ ### Revoking access
113
+
114
+ To stop the workflow from publishing:
115
+
116
+ 1. On PyPI, go to the project's publishing settings and remove the trusted publisher
117
+ 2. Optionally delete the `pypi` environment in GitHub repo settings
118
+
119
+ ## Troubleshooting
120
+
121
+ **"Trusted publisher not found"** -- The publisher fields on PyPI don't match the workflow. Double-check owner, repository, workflow filename, and environment name.
122
+
123
+ **"Version already exists"** -- PyPI does not allow overwriting a published version. Bump the version in `pyproject.toml` and create a new release.
124
+
125
+ **Tests fail and publish is skipped** -- The `publish` job depends on `test`. Fix the failing tests, then create a new release (or re-run the failed workflow from the Actions tab after the fix is on `main`).
@@ -0,0 +1,146 @@
1
+ # strands-token-telemetry
2
+
3
+ > Emit Strands Agents token usage as CloudWatch Embedded Metric Format (EMF) metrics.
4
+
5
+ ## Install
6
+
7
+ pip install strands-token-telemetry
8
+
9
+ strands-agents is a peer dependency (>= 0.1.0).
10
+
11
+ ## Public API
12
+
13
+ The package exports three names from strands_token_telemetry:
14
+
15
+ - TokenUsageHook — the main hook class (HookProvider)
16
+ - build_emf_payload — pure function to build an EMF dict
17
+ - default_emitter — prints compact JSON to stdout
18
+
19
+ ## TokenUsageHook
20
+
21
+ A Strands HookProvider. Pass it in the hooks list when creating an Agent.
22
+
23
+ Constructor (all parameters are keyword-only):
24
+
25
+ namespace: str = "Strands/AgentTokenUsage"
26
+ CloudWatch metrics namespace.
27
+
28
+ dimensions: list[list[str]] = [["Model"]]
29
+ Dimension key sets for CloudWatch metrics.
30
+
31
+ dimension_values: dict[str, str] = {}
32
+ Static dimension key/value pairs. Keys must appear in dimensions.
33
+
34
+ dimension_resolver: Callable[[AfterInvocationEvent], dict[str, str]] | None = None
35
+ Called on each invocation with the event. Returns dimension key/value
36
+ pairs that are merged with dimension_values (resolver wins on conflict).
37
+ Use this when a value isn't known until runtime (e.g. model name from
38
+ the provider response).
39
+
40
+ extra_properties: dict[str, Any] | None = None
41
+ Additional top-level properties in the EMF payload. These are NOT
42
+ published as CloudWatch Metric dimensions but ARE queryable in
43
+ CloudWatch Logs Insights. Use for user IDs, session IDs, trace IDs, etc.
44
+
45
+ emitter: Callable[[dict[str, Any]], None] = default_emitter
46
+ Function that receives the final payload dict. Override to route output
47
+ to logging, a webhook, or /dev/null.
48
+
49
+ ## Minimal usage
50
+
51
+ from strands import Agent
52
+ from strands_token_telemetry import TokenUsageHook
53
+
54
+ agent = Agent(hooks=[TokenUsageHook()])
55
+
56
+ ## Common patterns
57
+
58
+ ### Custom namespace with session context
59
+
60
+ hook = TokenUsageHook(
61
+ namespace="AcmeInc/StrandsTokens",
62
+ dimension_values={"Model": model_id},
63
+ extra_properties={"UserId": user_id, "SessionId": session_id},
64
+ )
65
+ agent = Agent(hooks=[hook])
66
+
67
+ ### Dynamic dimensions resolved at runtime
68
+
69
+ def resolve_dims(event):
70
+ model = getattr(event.result, "model", "unknown") if event.result else "unknown"
71
+ return {"Model": model}
72
+
73
+ agent = Agent(hooks=[
74
+ TokenUsageHook(
75
+ dimensions=[["Model", "Environment"]],
76
+ dimension_values={"Environment": "prod"},
77
+ dimension_resolver=resolve_dims,
78
+ )
79
+ ])
80
+
81
+ ### Suppress stdout output (local development)
82
+
83
+ hook = TokenUsageHook(emitter=lambda payload: None)
84
+
85
+ ### Pretty-print for debugging
86
+
87
+ import json
88
+ hook = TokenUsageHook(emitter=lambda p: print(json.dumps(p, indent=2)))
89
+
90
+ ### Route to Python logging
91
+
92
+ import json, logging
93
+ log = logging.getLogger("token_telemetry")
94
+ hook = TokenUsageHook(emitter=lambda p: log.debug("%s", json.dumps(p)))
95
+
96
+ ### Forward to an external service
97
+
98
+ import json, urllib.request
99
+
100
+ def webhook_emitter(payload):
101
+ req = urllib.request.Request(
102
+ "https://metrics.example.com/ingest",
103
+ data=json.dumps(payload).encode(),
104
+ headers={"Content-Type": "application/json"},
105
+ )
106
+ urllib.request.urlopen(req)
107
+
108
+ hook = TokenUsageHook(emitter=webhook_emitter)
109
+
110
+ ## build_emf_payload
111
+
112
+ Pure function — useful if you want to build the EMF dict without the hook.
113
+
114
+ build_emf_payload(
115
+ usage: dict[str, int], # token counters (each becomes a metric)
116
+ namespace: str,
117
+ dimensions: list[list[str]],
118
+ dimension_values: dict[str, str],
119
+ extra_properties: dict[str, Any] | None = None,
120
+ timestamp_ms: int | None = None, # defaults to now
121
+ ) -> dict[str, Any]
122
+
123
+ ## Metrics emitted
124
+
125
+ The hook emits every key present in the SDK's usage dict as a Count metric.
126
+ Typical keys: inputTokens, outputTokens, totalTokens, cacheReadInputTokens,
127
+ cacheWriteInputTokens.
128
+
129
+ ## Key concepts
130
+
131
+ - dimension_values vs extra_properties: dimension_values become CloudWatch
132
+ Metric dimensions (you can alarm/filter on them). extra_properties are
133
+ top-level JSON fields queryable in CloudWatch Logs Insights but not
134
+ published as metric dimensions.
135
+
136
+ - dimension_resolver vs dimension_values: use dimension_values for values
137
+ known at hook construction time (environment, service name). Use
138
+ dimension_resolver for values only available at invocation time (model
139
+ name from the response, agent name).
140
+
141
+ - emitter: the default prints compact JSON to stdout for the CloudWatch
142
+ agent. Override it to send elsewhere or suppress output.
143
+
144
+ ## Source
145
+
146
+ https://github.com/flockcover/strands-token-telemetry
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "strands-token-telemetry"
7
+ version = "0.1.0"
8
+ description = "Emit Strands agent token usage as CloudWatch EMF metrics"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Tom Harvey", email = "tom.harvey@flockcover.com" },
14
+ ]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.optional-dependencies]
23
+ dev = [
24
+ "pytest",
25
+ "ruff",
26
+ "strands-agents>=0.1.0",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/flockcover/strands-token-telemetry"
31
+ Issues = "https://github.com/flockcover/strands-token-telemetry/issues"
32
+
33
+ [tool.ruff]
34
+ line-length = 100
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
@@ -0,0 +1,8 @@
1
+ """strands-token-telemetry — emit Strands agent token usage as CloudWatch EMF metrics."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from strands_token_telemetry.emf import build_emf_payload
6
+ from strands_token_telemetry.hook import TokenUsageHook, default_emitter
7
+
8
+ __all__ = ["TokenUsageHook", "build_emf_payload", "default_emitter"]
@@ -0,0 +1,59 @@
1
+ """Pure function to build CloudWatch EMF (Embedded Metric Format) payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+
9
+ def build_emf_payload(
10
+ *,
11
+ usage: dict[str, int],
12
+ namespace: str,
13
+ dimensions: list[list[str]],
14
+ dimension_values: dict[str, str],
15
+ extra_properties: dict[str, Any] | None = None,
16
+ timestamp_ms: int | None = None,
17
+ ) -> dict[str, Any]:
18
+ """Build a CloudWatch EMF structured-log payload.
19
+
20
+ Args:
21
+ usage: Token usage counters — each key becomes a metric with Unit="Count".
22
+ namespace: CloudWatch metrics namespace.
23
+ dimensions: Dimension key sets (e.g. [["Model"], ["Model", "Agent"]]).
24
+ dimension_values: Concrete dimension key/value pairs emitted as top-level properties.
25
+ extra_properties: Additional top-level properties (searchable in CloudWatch Insights
26
+ but not published as metrics).
27
+ timestamp_ms: Epoch milliseconds for the metric datum. Defaults to now.
28
+
29
+ Returns:
30
+ A dict ready to be serialised as JSON and printed to stdout for the CloudWatch agent.
31
+ """
32
+ if timestamp_ms is None:
33
+ timestamp_ms = int(time.time() * 1000)
34
+
35
+ metrics = [{"Name": key, "Unit": "Count"} for key in usage]
36
+
37
+ payload: dict[str, Any] = {
38
+ "_aws": {
39
+ "Timestamp": timestamp_ms,
40
+ "CloudWatchMetrics": [
41
+ {
42
+ "Namespace": namespace,
43
+ "Dimensions": dimensions,
44
+ "Metrics": metrics,
45
+ }
46
+ ],
47
+ },
48
+ }
49
+
50
+ # Dimension values must be top-level properties (EMF spec).
51
+ payload.update(dimension_values)
52
+
53
+ # Metric values must also be top-level.
54
+ payload.update(usage)
55
+
56
+ if extra_properties:
57
+ payload.update(extra_properties)
58
+
59
+ return payload
@@ -0,0 +1,108 @@
1
+ """TokenUsageHook — a Strands HookProvider that emits token usage as CloudWatch EMF logs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import TYPE_CHECKING, Any, Callable
8
+
9
+ from strands_token_telemetry.emf import build_emf_payload
10
+
11
+ if TYPE_CHECKING:
12
+ from strands.hooks.events import AfterInvocationEvent
13
+ from strands.hooks.registry import HookRegistry
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ Emitter = Callable[[dict[str, Any]], None]
18
+
19
+
20
+ def default_emitter(payload: dict[str, Any]) -> None:
21
+ """Print compact JSON to stdout (picked up by the CloudWatch agent)."""
22
+ print(json.dumps(payload, separators=(",", ":")))
23
+
24
+
25
+ class TokenUsageHook:
26
+ """HookProvider that emits token-usage metrics in CloudWatch EMF format.
27
+
28
+ Usage::
29
+
30
+ from strands import Agent
31
+ from strands_token_telemetry import TokenUsageHook
32
+
33
+ agent = Agent(hooks=[TokenUsageHook(namespace="MyApp/Tokens")])
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ namespace: str = "Strands/AgentTokenUsage",
40
+ dimensions: list[list[str]] | None = None,
41
+ dimension_values: dict[str, str] | None = None,
42
+ dimension_resolver: Callable[[AfterInvocationEvent], dict[str, str]] | None = None,
43
+ extra_properties: dict[str, Any] | None = None,
44
+ emitter: Emitter | None = None,
45
+ ) -> None:
46
+ self.namespace = namespace
47
+ self.dimensions = dimensions if dimensions is not None else [["Model"]]
48
+ self.dimension_values = dimension_values or {}
49
+ self.dimension_resolver = dimension_resolver
50
+ self.extra_properties = extra_properties
51
+ self.emitter = emitter or default_emitter
52
+
53
+ # -- HookProvider protocol --------------------------------------------------
54
+
55
+ def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None:
56
+ from strands.hooks.events import AfterInvocationEvent
57
+
58
+ registry.add_callback(AfterInvocationEvent, self._on_after_invocation)
59
+
60
+ # -- internals --------------------------------------------------------------
61
+
62
+ @staticmethod
63
+ def _extract_usage(event: AfterInvocationEvent) -> dict[str, int] | None:
64
+ """Return token-usage dict from *event*, or ``None`` if unavailable."""
65
+ if event.result is None:
66
+ return None
67
+
68
+ # Primary path: event.result.metrics.accumulated_usage (newer SDK)
69
+ try:
70
+ usage = event.result.metrics.accumulated_usage
71
+ if usage:
72
+ return dict(usage)
73
+ except AttributeError:
74
+ pass
75
+
76
+ # Fallback: agent-level metrics (older SDK / original code path)
77
+ try:
78
+ invocations = event.agent.event_loop_metrics.agent_invocations
79
+ if invocations:
80
+ usage = invocations[-1].usage
81
+ if usage:
82
+ return dict(usage)
83
+ except AttributeError:
84
+ pass
85
+
86
+ return None
87
+
88
+ def _on_after_invocation(self, event: AfterInvocationEvent) -> None:
89
+ try:
90
+ usage = self._extract_usage(event)
91
+ if not usage:
92
+ return
93
+
94
+ dim_values = dict(self.dimension_values)
95
+ if self.dimension_resolver is not None:
96
+ dim_values.update(self.dimension_resolver(event))
97
+
98
+ payload = build_emf_payload(
99
+ usage=usage,
100
+ namespace=self.namespace,
101
+ dimensions=self.dimensions,
102
+ dimension_values=dim_values,
103
+ extra_properties=self.extra_properties,
104
+ )
105
+
106
+ self.emitter(payload)
107
+ except Exception:
108
+ logger.exception("TokenUsageHook: failed to emit token-usage metrics")
File without changes
@@ -0,0 +1,92 @@
1
+ """Pure unit tests for build_emf_payload — no mocks required."""
2
+
3
+ from strands_token_telemetry.emf import build_emf_payload
4
+
5
+
6
+ FIXED_TS = 1_700_000_000_000
7
+
8
+
9
+ def _make_payload(**overrides):
10
+ defaults = dict(
11
+ usage={"inputTokens": 100, "outputTokens": 50, "totalTokens": 150},
12
+ namespace="Test/NS",
13
+ dimensions=[["Model"]],
14
+ dimension_values={"Model": "test-model"},
15
+ timestamp_ms=FIXED_TS,
16
+ )
17
+ defaults.update(overrides)
18
+ return build_emf_payload(**defaults)
19
+
20
+
21
+ class TestPayloadStructure:
22
+ def test_aws_block_present(self):
23
+ payload = _make_payload()
24
+ assert "_aws" in payload
25
+ assert "Timestamp" in payload["_aws"]
26
+ assert "CloudWatchMetrics" in payload["_aws"]
27
+
28
+ def test_namespace_set(self):
29
+ payload = _make_payload()
30
+ cw = payload["_aws"]["CloudWatchMetrics"][0]
31
+ assert cw["Namespace"] == "Test/NS"
32
+
33
+ def test_timestamp_forwarded(self):
34
+ payload = _make_payload()
35
+ assert payload["_aws"]["Timestamp"] == FIXED_TS
36
+
37
+ def test_default_timestamp_generated(self):
38
+ payload = _make_payload(timestamp_ms=None)
39
+ ts = payload["_aws"]["Timestamp"]
40
+ # Should be a recent epoch-ms value (sanity check: after 2024-01-01).
41
+ assert ts > 1_704_067_200_000
42
+
43
+
44
+ class TestMetrics:
45
+ def test_metric_names_match_usage_keys(self):
46
+ usage = {"inputTokens": 10, "outputTokens": 20, "totalTokens": 30}
47
+ payload = _make_payload(usage=usage)
48
+ metric_names = {m["Name"] for m in payload["_aws"]["CloudWatchMetrics"][0]["Metrics"]}
49
+ assert metric_names == set(usage.keys())
50
+
51
+ def test_all_units_are_count(self):
52
+ payload = _make_payload()
53
+ for m in payload["_aws"]["CloudWatchMetrics"][0]["Metrics"]:
54
+ assert m["Unit"] == "Count"
55
+
56
+ def test_metric_values_top_level(self):
57
+ usage = {"inputTokens": 10, "outputTokens": 20, "totalTokens": 30}
58
+ payload = _make_payload(usage=usage)
59
+ for key, value in usage.items():
60
+ assert payload[key] == value
61
+
62
+
63
+ class TestDimensions:
64
+ def test_single_dimension_set(self):
65
+ payload = _make_payload(dimensions=[["Model"]])
66
+ assert payload["_aws"]["CloudWatchMetrics"][0]["Dimensions"] == [["Model"]]
67
+
68
+ def test_multiple_dimension_sets(self):
69
+ dims = [["Model"], ["Model", "Agent"]]
70
+ payload = _make_payload(dimensions=dims)
71
+ assert payload["_aws"]["CloudWatchMetrics"][0]["Dimensions"] == dims
72
+
73
+ def test_dimension_values_top_level(self):
74
+ payload = _make_payload(dimension_values={"Model": "gpt-4", "Agent": "bot"})
75
+ assert payload["Model"] == "gpt-4"
76
+ assert payload["Agent"] == "bot"
77
+
78
+
79
+ class TestExtraProperties:
80
+ def test_extra_properties_included(self):
81
+ payload = _make_payload(extra_properties={"requestId": "abc-123"})
82
+ assert payload["requestId"] == "abc-123"
83
+
84
+ def test_extra_properties_not_in_metrics(self):
85
+ payload = _make_payload(extra_properties={"requestId": "abc-123"})
86
+ metric_names = {m["Name"] for m in payload["_aws"]["CloudWatchMetrics"][0]["Metrics"]}
87
+ assert "requestId" not in metric_names
88
+
89
+ def test_none_extra_properties_ok(self):
90
+ payload = _make_payload(extra_properties=None)
91
+ # Should just not blow up; no extra keys beyond usage + dimensions + _aws.
92
+ assert "_aws" in payload
@@ -0,0 +1,139 @@
1
+ """Tests for TokenUsageHook — mocks Strands types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from unittest.mock import MagicMock
7
+
8
+ from strands_token_telemetry.hook import TokenUsageHook, default_emitter
9
+
10
+
11
+ def _make_event(usage=None, *, result_none=False):
12
+ """Build a minimal mock AfterInvocationEvent."""
13
+ event = MagicMock()
14
+ if result_none:
15
+ event.result = None
16
+ return event
17
+ event.result.metrics.accumulated_usage = usage or {
18
+ "inputTokens": 100,
19
+ "outputTokens": 50,
20
+ "totalTokens": 150,
21
+ }
22
+ return event
23
+
24
+
25
+ class TestEmitterCalled:
26
+ def test_emitter_receives_valid_payload(self):
27
+ emitter = MagicMock()
28
+ hook = TokenUsageHook(emitter=emitter)
29
+ event = _make_event()
30
+
31
+ hook._on_after_invocation(event)
32
+
33
+ emitter.assert_called_once()
34
+ payload = emitter.call_args[0][0]
35
+ assert "_aws" in payload
36
+ assert payload["inputTokens"] == 100
37
+
38
+ def test_no_emission_when_result_is_none(self):
39
+ emitter = MagicMock()
40
+ hook = TokenUsageHook(emitter=emitter)
41
+ event = _make_event(result_none=True)
42
+
43
+ hook._on_after_invocation(event)
44
+
45
+ emitter.assert_not_called()
46
+
47
+
48
+ class TestConfiguration:
49
+ def test_custom_namespace(self):
50
+ emitter = MagicMock()
51
+ hook = TokenUsageHook(namespace="Custom/NS", emitter=emitter)
52
+ hook._on_after_invocation(_make_event())
53
+
54
+ payload = emitter.call_args[0][0]
55
+ assert payload["_aws"]["CloudWatchMetrics"][0]["Namespace"] == "Custom/NS"
56
+
57
+ def test_custom_dimensions(self):
58
+ emitter = MagicMock()
59
+ dims = [["Model", "Agent"]]
60
+ hook = TokenUsageHook(dimensions=dims, emitter=emitter)
61
+ hook._on_after_invocation(_make_event())
62
+
63
+ payload = emitter.call_args[0][0]
64
+ assert payload["_aws"]["CloudWatchMetrics"][0]["Dimensions"] == dims
65
+
66
+ def test_static_dimension_values(self):
67
+ emitter = MagicMock()
68
+ hook = TokenUsageHook(
69
+ dimension_values={"Model": "claude-sonnet"},
70
+ emitter=emitter,
71
+ )
72
+ hook._on_after_invocation(_make_event())
73
+
74
+ payload = emitter.call_args[0][0]
75
+ assert payload["Model"] == "claude-sonnet"
76
+
77
+ def test_dimension_resolver_called_and_merged(self):
78
+ emitter = MagicMock()
79
+ resolver = MagicMock(return_value={"Model": "resolved-model", "Region": "us-east-1"})
80
+ hook = TokenUsageHook(
81
+ dimension_values={"Model": "static-model"},
82
+ dimension_resolver=resolver,
83
+ emitter=emitter,
84
+ )
85
+ event = _make_event()
86
+ hook._on_after_invocation(event)
87
+
88
+ resolver.assert_called_once_with(event)
89
+ payload = emitter.call_args[0][0]
90
+ # Resolver values override static ones.
91
+ assert payload["Model"] == "resolved-model"
92
+ assert payload["Region"] == "us-east-1"
93
+
94
+ def test_extra_properties_forwarded(self):
95
+ emitter = MagicMock()
96
+ hook = TokenUsageHook(
97
+ extra_properties={"requestId": "req-42"},
98
+ emitter=emitter,
99
+ )
100
+ hook._on_after_invocation(_make_event())
101
+
102
+ payload = emitter.call_args[0][0]
103
+ assert payload["requestId"] == "req-42"
104
+
105
+
106
+ class TestDefaultEmitter:
107
+ def test_prints_compact_json(self, capsys):
108
+ payload = {"key": "value", "num": 1}
109
+ default_emitter(payload)
110
+
111
+ captured = capsys.readouterr()
112
+ assert captured.out.strip() == json.dumps(payload, separators=(",", ":"))
113
+
114
+
115
+ class TestRegisterHooks:
116
+ def test_registers_after_invocation_callback(self):
117
+ from strands_token_telemetry.hook import TokenUsageHook
118
+
119
+ hook = TokenUsageHook()
120
+ registry = MagicMock()
121
+
122
+ hook.register_hooks(registry)
123
+
124
+ registry.add_callback.assert_called_once()
125
+ # First arg should be the AfterInvocationEvent class.
126
+ from strands.hooks.events import AfterInvocationEvent
127
+
128
+ event_type = registry.add_callback.call_args[0][0]
129
+ assert event_type is AfterInvocationEvent
130
+
131
+
132
+ class TestExceptionHandling:
133
+ def test_emitter_exception_is_caught(self):
134
+ def bad_emitter(payload):
135
+ raise RuntimeError("boom")
136
+
137
+ hook = TokenUsageHook(emitter=bad_emitter)
138
+ # Should not raise.
139
+ hook._on_after_invocation(_make_event())
@@ -0,0 +1,11 @@
1
+ from strands_token_telemetry import TokenUsageHook, build_emf_payload, default_emitter, __version__
2
+
3
+
4
+ def test_version():
5
+ assert __version__ == "0.1.0"
6
+
7
+
8
+ def test_public_exports():
9
+ assert callable(TokenUsageHook)
10
+ assert callable(build_emf_payload)
11
+ assert callable(default_emitter)