armature-mcp-analytics 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.
- armature_mcp_analytics-0.1.0/.github/workflows/ci.yml +44 -0
- armature_mcp_analytics-0.1.0/.github/workflows/publish.yml +140 -0
- armature_mcp_analytics-0.1.0/PKG-INFO +72 -0
- armature_mcp_analytics-0.1.0/README.md +48 -0
- armature_mcp_analytics-0.1.0/pyproject.toml +48 -0
- armature_mcp_analytics-0.1.0/src/armature_mcp_analytics/__init__.py +47 -0
- armature_mcp_analytics-0.1.0/src/armature_mcp_analytics/emit.py +152 -0
- armature_mcp_analytics-0.1.0/src/armature_mcp_analytics/events.py +238 -0
- armature_mcp_analytics-0.1.0/src/armature_mcp_analytics/recorder.py +288 -0
- armature_mcp_analytics-0.1.0/src/armature_mcp_analytics/schema.py +111 -0
- armature_mcp_analytics-0.1.0/src/armature_mcp_analytics/server.py +235 -0
- armature_mcp_analytics-0.1.0/src/armature_mcp_analytics/types.py +96 -0
- armature_mcp_analytics-0.1.0/src/armature_mcp_analytics/utils.py +98 -0
- armature_mcp_analytics-0.1.0/tests/__init__.py +1 -0
- armature_mcp_analytics-0.1.0/tests/test_emit.py +63 -0
- armature_mcp_analytics-0.1.0/tests/test_fastmcp_adapter.py +288 -0
- armature_mcp_analytics-0.1.0/tests/test_recorder.py +216 -0
- armature_mcp_analytics-0.1.0/tests/test_schema.py +56 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
push:
|
|
8
|
+
branches:
|
|
9
|
+
- main
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
checks:
|
|
16
|
+
name: Checks
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
timeout-minutes: 10
|
|
19
|
+
permissions:
|
|
20
|
+
contents: read
|
|
21
|
+
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
|
22
|
+
|
|
23
|
+
strategy:
|
|
24
|
+
fail-fast: false
|
|
25
|
+
matrix:
|
|
26
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
27
|
+
|
|
28
|
+
steps:
|
|
29
|
+
- name: Checkout
|
|
30
|
+
uses: actions/checkout@v4
|
|
31
|
+
|
|
32
|
+
- name: Setup Python
|
|
33
|
+
uses: actions/setup-python@v5
|
|
34
|
+
with:
|
|
35
|
+
python-version: ${{ matrix.python-version }}
|
|
36
|
+
|
|
37
|
+
- name: Run checks
|
|
38
|
+
run: PYTHONPATH=src python -m unittest discover -s tests
|
|
39
|
+
|
|
40
|
+
- name: Build package
|
|
41
|
+
run: |
|
|
42
|
+
python -m pip install --upgrade build
|
|
43
|
+
python -m build
|
|
44
|
+
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
id-token: write
|
|
11
|
+
|
|
12
|
+
concurrency:
|
|
13
|
+
group: publish-pypi
|
|
14
|
+
cancel-in-progress: false
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
publish:
|
|
18
|
+
name: Publish to PyPI
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
timeout-minutes: 15
|
|
21
|
+
if: github.repository == 'armature-tech/mcp-analytics-python'
|
|
22
|
+
|
|
23
|
+
steps:
|
|
24
|
+
- name: Checkout
|
|
25
|
+
uses: actions/checkout@v4
|
|
26
|
+
with:
|
|
27
|
+
fetch-depth: 0
|
|
28
|
+
|
|
29
|
+
- name: Check whether this commit is already released
|
|
30
|
+
id: existing_release
|
|
31
|
+
run: |
|
|
32
|
+
set -euo pipefail
|
|
33
|
+
tag="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -1 || true)"
|
|
34
|
+
if [[ -n "${tag}" ]]; then
|
|
35
|
+
echo "Commit ${GITHUB_SHA} already has release tag ${tag}; skipping publish."
|
|
36
|
+
echo "published=true" >> "${GITHUB_OUTPUT}"
|
|
37
|
+
else
|
|
38
|
+
echo "published=false" >> "${GITHUB_OUTPUT}"
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
- name: Setup Python
|
|
42
|
+
if: steps.existing_release.outputs.published != 'true'
|
|
43
|
+
uses: actions/setup-python@v5
|
|
44
|
+
with:
|
|
45
|
+
python-version: "3.12"
|
|
46
|
+
|
|
47
|
+
- name: Run checks
|
|
48
|
+
if: steps.existing_release.outputs.published != 'true'
|
|
49
|
+
run: PYTHONPATH=src python -m unittest discover -s tests
|
|
50
|
+
|
|
51
|
+
- name: Resolve next patch version
|
|
52
|
+
if: steps.existing_release.outputs.published != 'true'
|
|
53
|
+
id: version
|
|
54
|
+
env:
|
|
55
|
+
INITIAL_VERSION: "0.1.0"
|
|
56
|
+
run: |
|
|
57
|
+
set -euo pipefail
|
|
58
|
+
python <<'PY'
|
|
59
|
+
import json
|
|
60
|
+
import os
|
|
61
|
+
import re
|
|
62
|
+
import subprocess
|
|
63
|
+
import sys
|
|
64
|
+
import urllib.error
|
|
65
|
+
import urllib.request
|
|
66
|
+
from pathlib import Path
|
|
67
|
+
|
|
68
|
+
pyproject_path = Path("pyproject.toml")
|
|
69
|
+
text = pyproject_path.read_text()
|
|
70
|
+
name_match = re.search(r'^name = "([^"]+)"$', text, re.MULTILINE)
|
|
71
|
+
if not name_match:
|
|
72
|
+
raise SystemExit("Could not find project name in pyproject.toml")
|
|
73
|
+
package_name = name_match.group(1)
|
|
74
|
+
|
|
75
|
+
versions = []
|
|
76
|
+
|
|
77
|
+
def add_version(candidate):
|
|
78
|
+
if not isinstance(candidate, str):
|
|
79
|
+
return
|
|
80
|
+
normalized = candidate.strip().removeprefix("v")
|
|
81
|
+
if re.fullmatch(r"\d+\.\d+\.\d+", normalized):
|
|
82
|
+
versions.append(normalized)
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
with urllib.request.urlopen(f"https://pypi.org/pypi/{package_name}/json", timeout=15) as response:
|
|
86
|
+
data = json.load(response)
|
|
87
|
+
for version in data.get("releases", {}):
|
|
88
|
+
add_version(version)
|
|
89
|
+
except urllib.error.HTTPError as error:
|
|
90
|
+
if error.code != 404:
|
|
91
|
+
print(f"Could not read PyPI versions for {package_name}: HTTP {error.code}", file=sys.stderr)
|
|
92
|
+
except Exception as error:
|
|
93
|
+
print(f"Could not read PyPI versions for {package_name}: {error}", file=sys.stderr)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
subprocess.run(["git", "fetch", "--tags", "--force"], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
97
|
+
tags = subprocess.check_output(["git", "tag", "-l", "v*"], text=True)
|
|
98
|
+
for tag in tags.splitlines():
|
|
99
|
+
add_version(tag)
|
|
100
|
+
except Exception as error:
|
|
101
|
+
print(f"Could not read git tags: {error}", file=sys.stderr)
|
|
102
|
+
|
|
103
|
+
if versions:
|
|
104
|
+
def parsed(version):
|
|
105
|
+
return tuple(int(part) for part in version.split("."))
|
|
106
|
+
major, minor, patch = max(parsed(version) for version in versions)
|
|
107
|
+
next_version = f"{major}.{minor}.{patch + 1}"
|
|
108
|
+
else:
|
|
109
|
+
next_version = os.environ["INITIAL_VERSION"]
|
|
110
|
+
|
|
111
|
+
text = re.sub(r'^version = "[^"]+"$', f'version = "{next_version}"', text, count=1, flags=re.MULTILINE)
|
|
112
|
+
pyproject_path.write_text(text)
|
|
113
|
+
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
|
|
114
|
+
output.write(f"next_version={next_version}\n")
|
|
115
|
+
print(f"Publishing {package_name}=={next_version}")
|
|
116
|
+
PY
|
|
117
|
+
|
|
118
|
+
- name: Build distributions
|
|
119
|
+
if: steps.existing_release.outputs.published != 'true'
|
|
120
|
+
run: |
|
|
121
|
+
python -m pip install --upgrade build
|
|
122
|
+
python -m build
|
|
123
|
+
python -m tarfile -l dist/*.tar.gz
|
|
124
|
+
python -m zipfile -l dist/*.whl
|
|
125
|
+
|
|
126
|
+
- name: Publish
|
|
127
|
+
if: steps.existing_release.outputs.published != 'true'
|
|
128
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
129
|
+
|
|
130
|
+
- name: Create GitHub release
|
|
131
|
+
if: steps.existing_release.outputs.published != 'true'
|
|
132
|
+
env:
|
|
133
|
+
GH_TOKEN: ${{ github.token }}
|
|
134
|
+
NEXT_VERSION: ${{ steps.version.outputs.next_version }}
|
|
135
|
+
run: |
|
|
136
|
+
set -euo pipefail
|
|
137
|
+
gh release create "v${NEXT_VERSION}" \
|
|
138
|
+
--target "${GITHUB_SHA}" \
|
|
139
|
+
--title "v${NEXT_VERSION}" \
|
|
140
|
+
--notes "Published armature-mcp-analytics==${NEXT_VERSION} from ${GITHUB_SHA}."
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: armature-mcp-analytics
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Armature analytics wrapper SDK for Python MCP servers.
|
|
5
|
+
Project-URL: Homepage, https://armature.tech
|
|
6
|
+
Project-URL: Repository, https://github.com/armature-tech/mcp-analytics-python
|
|
7
|
+
Author-email: Armature <support@armature.tech>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: analytics,armature,fastmcp,mcp,model-context-protocol,observability,telemetry
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Provides-Extra: fastmcp
|
|
20
|
+
Requires-Dist: fastmcp<4,>=2; extra == 'fastmcp'
|
|
21
|
+
Provides-Extra: mcp
|
|
22
|
+
Requires-Dist: mcp<2,>=1.27; extra == 'mcp'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# armature-mcp-analytics
|
|
26
|
+
|
|
27
|
+
Armature analytics for Python MCP servers. The v1 integration is FastMCP-first:
|
|
28
|
+
instrument a `FastMCP` instance, keep writing normal tools, and Armature records
|
|
29
|
+
tool calls with MCP-specific telemetry.
|
|
30
|
+
|
|
31
|
+
Install:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install "armature-mcp-analytics[fastmcp]"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import os
|
|
39
|
+
from fastmcp import FastMCP
|
|
40
|
+
from armature_mcp_analytics import instrument_fastmcp
|
|
41
|
+
|
|
42
|
+
mcp = FastMCP("Customer MCP")
|
|
43
|
+
|
|
44
|
+
instrument_fastmcp(
|
|
45
|
+
mcp,
|
|
46
|
+
{
|
|
47
|
+
"armature": {
|
|
48
|
+
"api_key": os.getenv("ANALYTICS_INGEST_API_KEY"),
|
|
49
|
+
"delivery": "await",
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@mcp.tool
|
|
55
|
+
def lookup_customer(customer_id: str) -> dict:
|
|
56
|
+
"""Look up a customer by id."""
|
|
57
|
+
return {"customer_id": customer_id, "status": "active"}
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
mcp.run()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Agents see an optional `telemetry` object on each tool input schema. The SDK
|
|
64
|
+
strips that object before your handler runs, then emits an authenticated batch
|
|
65
|
+
to Armature.
|
|
66
|
+
|
|
67
|
+
Environment variables:
|
|
68
|
+
|
|
69
|
+
| Variable | Purpose |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `ANALYTICS_INGEST_API_KEY` | Armature ingest API key. Missing keys no-op for local development. |
|
|
72
|
+
| `ANALYTICS_INGEST_URL` | Optional ingest endpoint override. |
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# armature-mcp-analytics
|
|
2
|
+
|
|
3
|
+
Armature analytics for Python MCP servers. The v1 integration is FastMCP-first:
|
|
4
|
+
instrument a `FastMCP` instance, keep writing normal tools, and Armature records
|
|
5
|
+
tool calls with MCP-specific telemetry.
|
|
6
|
+
|
|
7
|
+
Install:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install "armature-mcp-analytics[fastmcp]"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import os
|
|
15
|
+
from fastmcp import FastMCP
|
|
16
|
+
from armature_mcp_analytics import instrument_fastmcp
|
|
17
|
+
|
|
18
|
+
mcp = FastMCP("Customer MCP")
|
|
19
|
+
|
|
20
|
+
instrument_fastmcp(
|
|
21
|
+
mcp,
|
|
22
|
+
{
|
|
23
|
+
"armature": {
|
|
24
|
+
"api_key": os.getenv("ANALYTICS_INGEST_API_KEY"),
|
|
25
|
+
"delivery": "await",
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
@mcp.tool
|
|
31
|
+
def lookup_customer(customer_id: str) -> dict:
|
|
32
|
+
"""Look up a customer by id."""
|
|
33
|
+
return {"customer_id": customer_id, "status": "active"}
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
mcp.run()
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Agents see an optional `telemetry` object on each tool input schema. The SDK
|
|
40
|
+
strips that object before your handler runs, then emits an authenticated batch
|
|
41
|
+
to Armature.
|
|
42
|
+
|
|
43
|
+
Environment variables:
|
|
44
|
+
|
|
45
|
+
| Variable | Purpose |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `ANALYTICS_INGEST_API_KEY` | Armature ingest API key. Missing keys no-op for local development. |
|
|
48
|
+
| `ANALYTICS_INGEST_URL` | Optional ingest endpoint override. |
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "armature-mcp-analytics"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Armature analytics wrapper SDK for Python MCP servers."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Armature", email = "support@armature.tech" },
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"mcp",
|
|
17
|
+
"model-context-protocol",
|
|
18
|
+
"analytics",
|
|
19
|
+
"telemetry",
|
|
20
|
+
"observability",
|
|
21
|
+
"armature",
|
|
22
|
+
"fastmcp",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 3 - Alpha",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
"License :: OSI Approved :: Apache Software License",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
fastmcp = [
|
|
37
|
+
"fastmcp>=2,<4",
|
|
38
|
+
]
|
|
39
|
+
mcp = [
|
|
40
|
+
"mcp>=1.27,<2",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Homepage = "https://armature.tech"
|
|
45
|
+
Repository = "https://github.com/armature-tech/mcp-analytics-python"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/armature_mcp_analytics"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Armature analytics helpers for Python MCP servers."""
|
|
2
|
+
|
|
3
|
+
from .events import (
|
|
4
|
+
build_actor_id,
|
|
5
|
+
build_event_id,
|
|
6
|
+
build_session_init_event,
|
|
7
|
+
build_tool_call_event,
|
|
8
|
+
normalize_session_id,
|
|
9
|
+
)
|
|
10
|
+
from .recorder import AnalyticsRecorder, create_analytics_recorder
|
|
11
|
+
from .schema import (
|
|
12
|
+
append_telemetry_hint,
|
|
13
|
+
create_telemetry_json_schema,
|
|
14
|
+
decorate_input_schema_with_telemetry,
|
|
15
|
+
extract_telemetry_arguments,
|
|
16
|
+
)
|
|
17
|
+
from .server import FastMCPInstrumentation, instrument_fastmcp, with_mcp_analytics
|
|
18
|
+
from .types import (
|
|
19
|
+
AnalyticsConfig,
|
|
20
|
+
AnalyticsIngestBatch,
|
|
21
|
+
AnalyticsIngestEvent,
|
|
22
|
+
McpClientInfo,
|
|
23
|
+
TelemetryArgs,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AnalyticsConfig",
|
|
28
|
+
"AnalyticsIngestBatch",
|
|
29
|
+
"AnalyticsIngestEvent",
|
|
30
|
+
"AnalyticsRecorder",
|
|
31
|
+
"FastMCPInstrumentation",
|
|
32
|
+
"McpClientInfo",
|
|
33
|
+
"TelemetryArgs",
|
|
34
|
+
"append_telemetry_hint",
|
|
35
|
+
"build_actor_id",
|
|
36
|
+
"build_event_id",
|
|
37
|
+
"build_session_init_event",
|
|
38
|
+
"build_tool_call_event",
|
|
39
|
+
"create_analytics_recorder",
|
|
40
|
+
"create_telemetry_json_schema",
|
|
41
|
+
"decorate_input_schema_with_telemetry",
|
|
42
|
+
"extract_telemetry_arguments",
|
|
43
|
+
"instrument_fastmcp",
|
|
44
|
+
"normalize_session_id",
|
|
45
|
+
"with_mcp_analytics",
|
|
46
|
+
]
|
|
47
|
+
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .types import ActorIdResolverInput, AnalyticsConfig, AnalyticsIngestBatch
|
|
10
|
+
from .utils import header_value, read_env
|
|
11
|
+
|
|
12
|
+
DEFAULT_ENDPOINT_URL = "https://app.armature.tech/api/mcp-analytics/ingest"
|
|
13
|
+
DEFAULT_TIMEOUT_MS = 500
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _armature(config: AnalyticsConfig | None) -> dict[str, Any]:
|
|
17
|
+
return dict((config or {}).get("armature") or {})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _config_value(config: AnalyticsConfig | None, snake: str, camel: str, default: Any = None) -> Any:
|
|
21
|
+
armature = _armature(config)
|
|
22
|
+
if snake in armature:
|
|
23
|
+
return armature[snake]
|
|
24
|
+
if camel in armature:
|
|
25
|
+
return armature[camel]
|
|
26
|
+
return default
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_endpoint_url(config: AnalyticsConfig | None = None) -> str:
|
|
30
|
+
return (
|
|
31
|
+
_config_value(config, "endpoint_url", "endpointUrl")
|
|
32
|
+
or read_env("ANALYTICS_INGEST_URL")
|
|
33
|
+
or DEFAULT_ENDPOINT_URL
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resolve_api_key(config: AnalyticsConfig | None = None) -> str | None:
|
|
38
|
+
return _config_value(config, "api_key", "apiKey") or read_env("ANALYTICS_INGEST_API_KEY")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def resolve_actor_seed(config: AnalyticsConfig | None, input: ActorIdResolverInput) -> str:
|
|
42
|
+
actor_id = _config_value(config, "actor_id", "actorId")
|
|
43
|
+
if callable(actor_id):
|
|
44
|
+
value = actor_id(input)
|
|
45
|
+
if asyncio.iscoroutine(value) or isinstance(value, asyncio.Future):
|
|
46
|
+
value = await value
|
|
47
|
+
return str(value)
|
|
48
|
+
if actor_id:
|
|
49
|
+
return str(actor_id)
|
|
50
|
+
|
|
51
|
+
auth_info = input.get("authInfo") or {}
|
|
52
|
+
for key in ("token", "clientId", "apiKey", "principalId"):
|
|
53
|
+
value = auth_info.get(key)
|
|
54
|
+
if value:
|
|
55
|
+
return str(value)
|
|
56
|
+
|
|
57
|
+
authorization = header_value(input.get("headers"), "authorization")
|
|
58
|
+
if authorization:
|
|
59
|
+
return authorization
|
|
60
|
+
return "anonymous"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def post_telemetry_event(
|
|
64
|
+
batch: AnalyticsIngestBatch,
|
|
65
|
+
config: AnalyticsConfig | None = None,
|
|
66
|
+
) -> dict[str, Any]:
|
|
67
|
+
api_key = resolve_api_key(config)
|
|
68
|
+
if not api_key:
|
|
69
|
+
return {"skipped": True, "reason": "ingest_config_missing"}
|
|
70
|
+
|
|
71
|
+
body = json.dumps(batch, separators=(",", ":")).encode("utf-8")
|
|
72
|
+
request = urllib.request.Request(
|
|
73
|
+
resolve_endpoint_url(config),
|
|
74
|
+
data=body,
|
|
75
|
+
headers={
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
"Authorization": f"Bearer {api_key}",
|
|
78
|
+
},
|
|
79
|
+
method="POST",
|
|
80
|
+
)
|
|
81
|
+
timeout_ms = _config_value(config, "timeout_ms", "timeoutMs", DEFAULT_TIMEOUT_MS)
|
|
82
|
+
timeout = float(timeout_ms) / 1000
|
|
83
|
+
|
|
84
|
+
def send() -> dict[str, Any]:
|
|
85
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
86
|
+
status = response.getcode()
|
|
87
|
+
return {"skipped": False, "ok": True, "status": status}
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
return await asyncio.to_thread(send)
|
|
91
|
+
except urllib.error.HTTPError as error:
|
|
92
|
+
detail = error.read().decode("utf-8", errors="replace")
|
|
93
|
+
raise RuntimeError(f"Armature ingest failed with {error.code}: {detail}") from error
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class FlushableEmitter:
|
|
97
|
+
def __init__(self, config: AnalyticsConfig | None = None) -> None:
|
|
98
|
+
self.config = config or {}
|
|
99
|
+
self._pending: set[asyncio.Task[None]] = set()
|
|
100
|
+
|
|
101
|
+
def _enabled(self) -> bool:
|
|
102
|
+
return _config_value(self.config, "enabled", "enabled", True) is not False
|
|
103
|
+
|
|
104
|
+
def _delivery(self) -> str:
|
|
105
|
+
return str(_config_value(self.config, "delivery", "delivery", "background"))
|
|
106
|
+
|
|
107
|
+
def _emit_callable(self):
|
|
108
|
+
return _config_value(self.config, "emit", "emit")
|
|
109
|
+
|
|
110
|
+
def _on_error(self):
|
|
111
|
+
return _config_value(self.config, "on_error", "onError")
|
|
112
|
+
|
|
113
|
+
async def _run(self, batch: AnalyticsIngestBatch) -> None:
|
|
114
|
+
try:
|
|
115
|
+
emit = self._emit_callable()
|
|
116
|
+
if emit:
|
|
117
|
+
result = emit(batch)
|
|
118
|
+
if asyncio.iscoroutine(result) or isinstance(result, asyncio.Future):
|
|
119
|
+
await result
|
|
120
|
+
else:
|
|
121
|
+
await post_telemetry_event(batch, self.config)
|
|
122
|
+
except Exception as error:
|
|
123
|
+
# Telemetry delivery should not crash the host MCP server. Await
|
|
124
|
+
# mode waits for the attempt to finish; failures are surfaced only
|
|
125
|
+
# through the optional error hook, matching the JS SDK contract.
|
|
126
|
+
on_error = self._on_error()
|
|
127
|
+
if on_error:
|
|
128
|
+
try:
|
|
129
|
+
result = on_error(error, batch)
|
|
130
|
+
if asyncio.iscoroutine(result) or isinstance(result, asyncio.Future):
|
|
131
|
+
await result
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
async def emit_batch(self, batch: AnalyticsIngestBatch) -> None:
|
|
136
|
+
if not self._enabled():
|
|
137
|
+
return
|
|
138
|
+
if self._delivery() == "await":
|
|
139
|
+
await self._run(batch)
|
|
140
|
+
return
|
|
141
|
+
loop = asyncio.get_running_loop()
|
|
142
|
+
task = loop.create_task(self._run(batch))
|
|
143
|
+
self._pending.add(task)
|
|
144
|
+
task.add_done_callback(lambda done: self._pending.discard(done))
|
|
145
|
+
|
|
146
|
+
async def flush(self) -> None:
|
|
147
|
+
while self._pending:
|
|
148
|
+
await asyncio.gather(*list(self._pending), return_exceptions=True)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def create_flushable_emitter(config: AnalyticsConfig | None = None) -> FlushableEmitter:
|
|
152
|
+
return FlushableEmitter(config)
|