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.
- strands_token_telemetry-0.1.0/.github/workflows/publish.yml +53 -0
- strands_token_telemetry-0.1.0/.gitignore +12 -0
- strands_token_telemetry-0.1.0/LICENSE +21 -0
- strands_token_telemetry-0.1.0/PKG-INFO +273 -0
- strands_token_telemetry-0.1.0/README.md +254 -0
- strands_token_telemetry-0.1.0/docs/PUBLISHING.md +125 -0
- strands_token_telemetry-0.1.0/docs/llms.txt +146 -0
- strands_token_telemetry-0.1.0/pyproject.toml +37 -0
- strands_token_telemetry-0.1.0/src/strands_token_telemetry/__init__.py +8 -0
- strands_token_telemetry-0.1.0/src/strands_token_telemetry/emf.py +59 -0
- strands_token_telemetry-0.1.0/src/strands_token_telemetry/hook.py +108 -0
- strands_token_telemetry-0.1.0/src/strands_token_telemetry/py.typed +0 -0
- strands_token_telemetry-0.1.0/tests/__init__.py +0 -0
- strands_token_telemetry-0.1.0/tests/test_emf.py +92 -0
- strands_token_telemetry-0.1.0/tests/test_hook.py +139 -0
- strands_token_telemetry-0.1.0/tests/test_init.py +11 -0
|
@@ -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,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
|
|
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)
|