tellann 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.
- tellann-0.1.0/.gitignore +38 -0
- tellann-0.1.0/PKG-INFO +122 -0
- tellann-0.1.0/README.md +95 -0
- tellann-0.1.0/package.json +9 -0
- tellann-0.1.0/pyproject.toml +43 -0
- tellann-0.1.0/src/tellann/__init__.py +74 -0
- tellann-0.1.0/src/tellann/client.py +307 -0
- tellann-0.1.0/src/tellann/events.py +108 -0
- tellann-0.1.0/src/tellann/integrations/__init__.py +31 -0
- tellann-0.1.0/src/tellann/integrations/asgi_middleware.py +90 -0
- tellann-0.1.0/src/tellann/integrations/django_middleware.py +62 -0
- tellann-0.1.0/src/tellann/integrations/flask_extension.py +62 -0
- tellann-0.1.0/src/tellann/transport.py +171 -0
- tellann-0.1.0/tests/test_sdk.py +350 -0
tellann-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Dependency directories
|
|
2
|
+
node_modules/
|
|
3
|
+
.pnpm-store/
|
|
4
|
+
|
|
5
|
+
# Build and build cache outputs
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
release/
|
|
9
|
+
apps/desktop/release/
|
|
10
|
+
.next/
|
|
11
|
+
.codex/
|
|
12
|
+
.turbo/
|
|
13
|
+
out/
|
|
14
|
+
artifacts/
|
|
15
|
+
tmp/
|
|
16
|
+
**/tmp/
|
|
17
|
+
|
|
18
|
+
# Logs
|
|
19
|
+
*.log
|
|
20
|
+
*.err
|
|
21
|
+
|
|
22
|
+
# Env files
|
|
23
|
+
.env
|
|
24
|
+
.env.local
|
|
25
|
+
.env.development.local
|
|
26
|
+
.env.test.local
|
|
27
|
+
.env.production.local
|
|
28
|
+
|
|
29
|
+
# IDE/OS files
|
|
30
|
+
.DS_Store
|
|
31
|
+
Thumbs.db
|
|
32
|
+
.idea/
|
|
33
|
+
.vscode/
|
|
34
|
+
*.suo
|
|
35
|
+
*.ntvs*
|
|
36
|
+
*.njsproj
|
|
37
|
+
*.sln
|
|
38
|
+
*.sw?
|
tellann-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tellann
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tellann SDK for Python: behaviour telemetry for Django, Flask, FastAPI and Starlette applications.
|
|
5
|
+
Project-URL: Homepage, https://tellann.com
|
|
6
|
+
Author: Tellann
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: django,fastapi,flask,observability,telemetry
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: System :: Monitoring
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
20
|
+
Provides-Extra: django
|
|
21
|
+
Requires-Dist: django>=3.2; extra == 'django'
|
|
22
|
+
Provides-Extra: fastapi
|
|
23
|
+
Requires-Dist: fastapi>=0.95; extra == 'fastapi'
|
|
24
|
+
Provides-Extra: flask
|
|
25
|
+
Requires-Dist: flask>=2.0; extra == 'flask'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# tellann (Python SDK)
|
|
29
|
+
|
|
30
|
+
Behaviour telemetry for Python services, matching `@tellann/backend-sdk` event
|
|
31
|
+
for event. A Flow that crosses a React page and a Django view reaches the
|
|
32
|
+
collector as one sequence.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
python -m pip install tellann
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Initialize
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from tellann import TELLANN
|
|
42
|
+
|
|
43
|
+
TELLANN.initialize(
|
|
44
|
+
endpoint="https://gateway.example.com",
|
|
45
|
+
application_id="...",
|
|
46
|
+
environment_id="...",
|
|
47
|
+
api_key="...",
|
|
48
|
+
)
|
|
49
|
+
TELLANN.verify_installation()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Every argument falls back to an environment variable — `TELLANN_GATEWAY_URL`,
|
|
53
|
+
`TELLANN_INGESTION_KEY`, `TELLANN_APPLICATION_ID`, `TELLANN_ENVIRONMENT_ID` —
|
|
54
|
+
so `TELLANN.initialize()` with no arguments works wherever those are set. If
|
|
55
|
+
the endpoint or application id is missing the SDK disables itself rather than
|
|
56
|
+
raising: telemetry that is not configured must not stop an application booting.
|
|
57
|
+
|
|
58
|
+
## Frameworks
|
|
59
|
+
|
|
60
|
+
**Django** — add the middleware and initialize from `settings.py`:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from tellann.integrations.django_middleware import instrument_django
|
|
64
|
+
|
|
65
|
+
instrument_django()
|
|
66
|
+
|
|
67
|
+
MIDDLEWARE = [
|
|
68
|
+
"tellann.integrations.django_middleware.TellannMiddleware",
|
|
69
|
+
# ... your middleware
|
|
70
|
+
]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Flask** — attach to the application, including inside a factory:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from tellann import instrument_flask
|
|
77
|
+
|
|
78
|
+
app = Flask(__name__)
|
|
79
|
+
instrument_flask(app)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**FastAPI / Starlette** — attach to the application:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from tellann import instrument_fastapi
|
|
86
|
+
|
|
87
|
+
app = FastAPI()
|
|
88
|
+
instrument_fastapi(app)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Each integration reports the matched **route template** (`/invoices/{pk}`),
|
|
92
|
+
never the concrete path, so identifiers in URLs stay out of telemetry and the
|
|
93
|
+
graph sees one endpoint instead of one per request.
|
|
94
|
+
|
|
95
|
+
## Primitives
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
TELLANN.track_api("POST", "/invoices/{pk}", 201, duration_ms=12.5)
|
|
99
|
+
TELLANN.track_state("AWAITING_PAYMENT", previous_state="DRAFT")
|
|
100
|
+
TELLANN.capture_error(error)
|
|
101
|
+
TELLANN.track_event("BUSINESS_EVENT", {"plan": "pro"})
|
|
102
|
+
|
|
103
|
+
with TELLANN.workflow("checkout") as workflow_id:
|
|
104
|
+
charge(...) # failure fails the workflow and re-raises
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Behaviour under load and failure
|
|
108
|
+
|
|
109
|
+
Delivery runs on a daemon thread behind a bounded queue. A collector that is
|
|
110
|
+
down, slow or unreachable costs a dropped event and nothing else — no blocked
|
|
111
|
+
request, no raised exception, no unbounded memory. Events larger than the
|
|
112
|
+
collector's 32 KB limit are dropped locally with a warning rather than sent and
|
|
113
|
+
discarded on the other side.
|
|
114
|
+
|
|
115
|
+
The package has **no runtime dependencies**; delivery uses only the standard
|
|
116
|
+
library, so it never conflicts with the versions an application has pinned.
|
|
117
|
+
|
|
118
|
+
## Development
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
python -m unittest discover -s tests
|
|
122
|
+
```
|
tellann-0.1.0/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# tellann (Python SDK)
|
|
2
|
+
|
|
3
|
+
Behaviour telemetry for Python services, matching `@tellann/backend-sdk` event
|
|
4
|
+
for event. A Flow that crosses a React page and a Django view reaches the
|
|
5
|
+
collector as one sequence.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
python -m pip install tellann
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Initialize
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from tellann import TELLANN
|
|
15
|
+
|
|
16
|
+
TELLANN.initialize(
|
|
17
|
+
endpoint="https://gateway.example.com",
|
|
18
|
+
application_id="...",
|
|
19
|
+
environment_id="...",
|
|
20
|
+
api_key="...",
|
|
21
|
+
)
|
|
22
|
+
TELLANN.verify_installation()
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Every argument falls back to an environment variable — `TELLANN_GATEWAY_URL`,
|
|
26
|
+
`TELLANN_INGESTION_KEY`, `TELLANN_APPLICATION_ID`, `TELLANN_ENVIRONMENT_ID` —
|
|
27
|
+
so `TELLANN.initialize()` with no arguments works wherever those are set. If
|
|
28
|
+
the endpoint or application id is missing the SDK disables itself rather than
|
|
29
|
+
raising: telemetry that is not configured must not stop an application booting.
|
|
30
|
+
|
|
31
|
+
## Frameworks
|
|
32
|
+
|
|
33
|
+
**Django** — add the middleware and initialize from `settings.py`:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from tellann.integrations.django_middleware import instrument_django
|
|
37
|
+
|
|
38
|
+
instrument_django()
|
|
39
|
+
|
|
40
|
+
MIDDLEWARE = [
|
|
41
|
+
"tellann.integrations.django_middleware.TellannMiddleware",
|
|
42
|
+
# ... your middleware
|
|
43
|
+
]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Flask** — attach to the application, including inside a factory:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from tellann import instrument_flask
|
|
50
|
+
|
|
51
|
+
app = Flask(__name__)
|
|
52
|
+
instrument_flask(app)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**FastAPI / Starlette** — attach to the application:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from tellann import instrument_fastapi
|
|
59
|
+
|
|
60
|
+
app = FastAPI()
|
|
61
|
+
instrument_fastapi(app)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Each integration reports the matched **route template** (`/invoices/{pk}`),
|
|
65
|
+
never the concrete path, so identifiers in URLs stay out of telemetry and the
|
|
66
|
+
graph sees one endpoint instead of one per request.
|
|
67
|
+
|
|
68
|
+
## Primitives
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
TELLANN.track_api("POST", "/invoices/{pk}", 201, duration_ms=12.5)
|
|
72
|
+
TELLANN.track_state("AWAITING_PAYMENT", previous_state="DRAFT")
|
|
73
|
+
TELLANN.capture_error(error)
|
|
74
|
+
TELLANN.track_event("BUSINESS_EVENT", {"plan": "pro"})
|
|
75
|
+
|
|
76
|
+
with TELLANN.workflow("checkout") as workflow_id:
|
|
77
|
+
charge(...) # failure fails the workflow and re-raises
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Behaviour under load and failure
|
|
81
|
+
|
|
82
|
+
Delivery runs on a daemon thread behind a bounded queue. A collector that is
|
|
83
|
+
down, slow or unreachable costs a dropped event and nothing else — no blocked
|
|
84
|
+
request, no raised exception, no unbounded memory. Events larger than the
|
|
85
|
+
collector's 32 KB limit are dropped locally with a warning rather than sent and
|
|
86
|
+
discarded on the other side.
|
|
87
|
+
|
|
88
|
+
The package has **no runtime dependencies**; delivery uses only the standard
|
|
89
|
+
library, so it never conflicts with the versions an application has pinned.
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
python -m unittest discover -s tests
|
|
95
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tellann/python-sdk",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Workspace entry for the Tellann Python SDK. The package itself is published to PyPI as `tellann`; this manifest exists so changesets versions it alongside the npm SDKs and turbo runs its tests.",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "python -m unittest discover -s tests"
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tellann"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Tellann SDK for Python: behaviour telemetry for Django, Flask, FastAPI and Starlette applications."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Tellann" }]
|
|
13
|
+
keywords = ["telemetry", "observability", "django", "flask", "fastapi"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: System :: Monitoring",
|
|
23
|
+
]
|
|
24
|
+
# No runtime dependencies on purpose: the SDK is installed into applications
|
|
25
|
+
# that pin their own versions of everything, and delivery needs only the
|
|
26
|
+
# standard library.
|
|
27
|
+
dependencies = []
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
django = ["Django>=3.2"]
|
|
31
|
+
flask = ["Flask>=2.0"]
|
|
32
|
+
fastapi = ["fastapi>=0.95"]
|
|
33
|
+
dev = ["pytest>=8"]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://tellann.com"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/tellann"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Tellann SDK for Python.
|
|
2
|
+
|
|
3
|
+
The public surface mirrors `@tellann/backend-sdk`, so the same concepts appear
|
|
4
|
+
under the same names in both languages::
|
|
5
|
+
|
|
6
|
+
from tellann import TELLANN
|
|
7
|
+
|
|
8
|
+
TELLANN.initialize(
|
|
9
|
+
endpoint="https://gateway.example.com",
|
|
10
|
+
application_id="...",
|
|
11
|
+
environment_id="...",
|
|
12
|
+
)
|
|
13
|
+
TELLANN.verify_installation()
|
|
14
|
+
|
|
15
|
+
Framework integrations are imported on demand, so installing this package never
|
|
16
|
+
requires a framework the project does not use.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from .client import TELLANN, TellannBackend
|
|
22
|
+
from .events import EVENT_TYPES, MAX_EVENT_BYTES, SOURCE, TellannEvent
|
|
23
|
+
from .transport import EventTransport
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"EVENT_TYPES",
|
|
29
|
+
"EventTransport",
|
|
30
|
+
"MAX_EVENT_BYTES",
|
|
31
|
+
"SOURCE",
|
|
32
|
+
"TELLANN",
|
|
33
|
+
"TellannBackend",
|
|
34
|
+
"TellannEvent",
|
|
35
|
+
"__version__",
|
|
36
|
+
"capture_error",
|
|
37
|
+
"checkpoint",
|
|
38
|
+
"instrument_django",
|
|
39
|
+
"instrument_fastapi",
|
|
40
|
+
"instrument_flask",
|
|
41
|
+
"instrument_starlette",
|
|
42
|
+
"track_api",
|
|
43
|
+
"track_event",
|
|
44
|
+
"track_state",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def track_api(*args, **kwargs) -> None:
|
|
49
|
+
"""Module-level helper, matching the JavaScript SDK's loose functions."""
|
|
50
|
+
TELLANN.track_api(*args, **kwargs)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def track_event(*args, **kwargs) -> None:
|
|
54
|
+
TELLANN.track_event(*args, **kwargs)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def track_state(*args, **kwargs) -> None:
|
|
58
|
+
TELLANN.track_state(*args, **kwargs)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def capture_error(*args, **kwargs) -> None:
|
|
62
|
+
TELLANN.capture_error(*args, **kwargs)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def checkpoint(*args, **kwargs) -> None:
|
|
66
|
+
TELLANN.checkpoint(*args, **kwargs)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def __getattr__(name: str):
|
|
70
|
+
if name.startswith("instrument_"):
|
|
71
|
+
from . import integrations
|
|
72
|
+
|
|
73
|
+
return getattr(integrations, name)
|
|
74
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""The `TELLANN` singleton, mirroring `@tellann/backend-sdk`.
|
|
2
|
+
|
|
3
|
+
Method names are snake_case because this is Python, but every one of them has a
|
|
4
|
+
camelCase counterpart in the JavaScript SDK and produces an identical event. A
|
|
5
|
+
Flow instrumented across a React page and a Django view therefore reaches the
|
|
6
|
+
collector as one sequence, not two dialects of one.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import os
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import traceback
|
|
16
|
+
import uuid
|
|
17
|
+
from typing import Any, Dict, Iterator, Optional
|
|
18
|
+
|
|
19
|
+
from .events import EVENT_TYPES, SOURCE, TellannEvent
|
|
20
|
+
from .transport import EventTransport
|
|
21
|
+
|
|
22
|
+
#: Environment variables read when `initialize` is called without arguments.
|
|
23
|
+
#: The names match what the desktop instrumenter writes into `.env`.
|
|
24
|
+
ENV_ENDPOINT = "TELLANN_GATEWAY_URL"
|
|
25
|
+
ENV_API_KEY = "TELLANN_INGESTION_KEY"
|
|
26
|
+
ENV_APPLICATION_ID = "TELLANN_APPLICATION_ID"
|
|
27
|
+
ENV_ENVIRONMENT_ID = "TELLANN_ENVIRONMENT_ID"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TellannBackend:
|
|
31
|
+
"""Configuration, session identity and event construction."""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self._transport: Optional[EventTransport] = None
|
|
35
|
+
self._config: Optional[Dict[str, Any]] = None
|
|
36
|
+
self._workflows: Dict[str, Dict[str, Any]] = {}
|
|
37
|
+
self._lock = threading.Lock()
|
|
38
|
+
|
|
39
|
+
# ── lifecycle ─────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
def initialize(
|
|
42
|
+
self,
|
|
43
|
+
endpoint: Optional[str] = None,
|
|
44
|
+
*,
|
|
45
|
+
application_id: Optional[str] = None,
|
|
46
|
+
api_key: Optional[str] = None,
|
|
47
|
+
environment_id: Optional[str] = None,
|
|
48
|
+
tenant_id: Optional[str] = None,
|
|
49
|
+
session_id: Optional[str] = None,
|
|
50
|
+
run_id: Optional[str] = None,
|
|
51
|
+
trace_id: Optional[str] = None,
|
|
52
|
+
agent_version: Optional[str] = None,
|
|
53
|
+
instrumentation_manifest_version: Optional[str] = None,
|
|
54
|
+
transport: Optional[EventTransport] = None,
|
|
55
|
+
) -> "TellannBackend":
|
|
56
|
+
"""Configure the SDK.
|
|
57
|
+
|
|
58
|
+
Every argument falls back to its environment variable, so a generated
|
|
59
|
+
module can call `initialize()` with no arguments and still work in an
|
|
60
|
+
environment where the values are supplied by deployment rather than by
|
|
61
|
+
the checked-in source.
|
|
62
|
+
"""
|
|
63
|
+
resolved_endpoint = endpoint or os.environ.get(ENV_ENDPOINT)
|
|
64
|
+
resolved_application = application_id or os.environ.get(ENV_APPLICATION_ID)
|
|
65
|
+
if not resolved_endpoint or not resolved_application:
|
|
66
|
+
# Misconfiguration disables the SDK rather than raising: telemetry
|
|
67
|
+
# that is not set up must not stop an application from booting.
|
|
68
|
+
self._config = None
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
self._config = {
|
|
72
|
+
"endpoint": resolved_endpoint.rstrip("/"),
|
|
73
|
+
"application_id": resolved_application,
|
|
74
|
+
"environment_id": environment_id or os.environ.get(ENV_ENVIRONMENT_ID),
|
|
75
|
+
"tenant_id": tenant_id or "unknown",
|
|
76
|
+
"session_id": session_id or str(uuid.uuid4()),
|
|
77
|
+
"run_id": run_id or os.environ.get("TELLANN_RUN_ID"),
|
|
78
|
+
"trace_id": trace_id or os.environ.get("TELLANN_TRACE_ID"),
|
|
79
|
+
"agent_version": agent_version,
|
|
80
|
+
"instrumentation_manifest_version": instrumentation_manifest_version,
|
|
81
|
+
}
|
|
82
|
+
self._transport = transport or EventTransport(
|
|
83
|
+
self._config["endpoint"], api_key or os.environ.get(ENV_API_KEY)
|
|
84
|
+
)
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def is_initialized(self) -> bool:
|
|
88
|
+
return self._config is not None
|
|
89
|
+
|
|
90
|
+
def get_config(self) -> Optional[Dict[str, Any]]:
|
|
91
|
+
return dict(self._config) if self._config else None
|
|
92
|
+
|
|
93
|
+
def teardown(self) -> None:
|
|
94
|
+
"""Release the worker thread. Used by tests and by worker shutdown."""
|
|
95
|
+
if self._transport is not None:
|
|
96
|
+
self._transport.close()
|
|
97
|
+
self._transport = None
|
|
98
|
+
self._config = None
|
|
99
|
+
self._workflows.clear()
|
|
100
|
+
|
|
101
|
+
def flush(self, timeout: Optional[float] = None) -> None:
|
|
102
|
+
if self._transport is not None:
|
|
103
|
+
self._transport.flush(timeout)
|
|
104
|
+
|
|
105
|
+
# ── event construction ────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
def track_event(
|
|
108
|
+
self,
|
|
109
|
+
event_type: str,
|
|
110
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
111
|
+
session_id: Optional[str] = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Send one event. Unknown event types are sent as given.
|
|
114
|
+
|
|
115
|
+
The collector owns the authoritative list, and refusing to send a type
|
|
116
|
+
this SDK version has not heard of would make every new event type a
|
|
117
|
+
blocking SDK upgrade.
|
|
118
|
+
"""
|
|
119
|
+
if self._config is None or self._transport is None:
|
|
120
|
+
return
|
|
121
|
+
event = TellannEvent(
|
|
122
|
+
event_type=event_type,
|
|
123
|
+
application_id=self._config["application_id"],
|
|
124
|
+
session_id=session_id or self._config["session_id"],
|
|
125
|
+
tenant_id=self._config["tenant_id"],
|
|
126
|
+
environment_id=self._config["environment_id"],
|
|
127
|
+
run_id=self._config["run_id"],
|
|
128
|
+
trace_id=self._config["trace_id"],
|
|
129
|
+
agent_version=self._config["agent_version"],
|
|
130
|
+
instrumentation_manifest_version=self._config["instrumentation_manifest_version"],
|
|
131
|
+
metadata=metadata or {},
|
|
132
|
+
source=SOURCE,
|
|
133
|
+
)
|
|
134
|
+
self._transport.send(event.to_payload())
|
|
135
|
+
|
|
136
|
+
def verify_installation(self, session_id: Optional[str] = None) -> None:
|
|
137
|
+
"""Prove the SDK is wired up, which is what marks the target connected."""
|
|
138
|
+
self.track_event(
|
|
139
|
+
"TELLANN_INITIALIZED",
|
|
140
|
+
{"source": "manual_verification", "verificationKind": "BOOTSTRAP_INITIALIZED"},
|
|
141
|
+
session_id,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# ── the four primitives the JavaScript SDK exposes ────────────────────────
|
|
145
|
+
|
|
146
|
+
def track_api(
|
|
147
|
+
self,
|
|
148
|
+
method: str,
|
|
149
|
+
route: str,
|
|
150
|
+
status_code: Optional[int] = None,
|
|
151
|
+
duration_ms: Optional[float] = None,
|
|
152
|
+
*,
|
|
153
|
+
session_id: Optional[str] = None,
|
|
154
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
155
|
+
) -> None:
|
|
156
|
+
payload: Dict[str, Any] = {"method": str(method).upper(), "route": route}
|
|
157
|
+
if status_code is not None:
|
|
158
|
+
payload["statusCode"] = int(status_code)
|
|
159
|
+
if duration_ms is not None:
|
|
160
|
+
payload["durationMs"] = round(float(duration_ms), 3)
|
|
161
|
+
payload.update(metadata or {})
|
|
162
|
+
self.track_event("API_REQUEST", payload, session_id)
|
|
163
|
+
|
|
164
|
+
def capture_error(
|
|
165
|
+
self,
|
|
166
|
+
error: BaseException | str,
|
|
167
|
+
*,
|
|
168
|
+
session_id: Optional[str] = None,
|
|
169
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
170
|
+
) -> None:
|
|
171
|
+
if isinstance(error, BaseException):
|
|
172
|
+
payload: Dict[str, Any] = {
|
|
173
|
+
"message": str(error),
|
|
174
|
+
"type": type(error).__name__,
|
|
175
|
+
# Bounded: a deep stack in a hot error path would otherwise
|
|
176
|
+
# push the event past the collector's size limit.
|
|
177
|
+
"stack": "".join(traceback.format_exception(error))[:4_000],
|
|
178
|
+
}
|
|
179
|
+
else:
|
|
180
|
+
payload = {"message": str(error), "type": "Error"}
|
|
181
|
+
payload.update(metadata or {})
|
|
182
|
+
self.track_event("SERVER_ERROR", payload, session_id)
|
|
183
|
+
|
|
184
|
+
def capture_message(
|
|
185
|
+
self,
|
|
186
|
+
message: str,
|
|
187
|
+
severity: str = "error",
|
|
188
|
+
session_id: Optional[str] = None,
|
|
189
|
+
) -> None:
|
|
190
|
+
self.track_event("SERVER_ERROR", {"message": message, "severity": severity}, session_id)
|
|
191
|
+
|
|
192
|
+
def track_state(
|
|
193
|
+
self,
|
|
194
|
+
state: str,
|
|
195
|
+
*,
|
|
196
|
+
previous_state: Optional[str] = None,
|
|
197
|
+
session_id: Optional[str] = None,
|
|
198
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
199
|
+
) -> None:
|
|
200
|
+
payload: Dict[str, Any] = {"state": state}
|
|
201
|
+
if previous_state:
|
|
202
|
+
payload["previousState"] = previous_state
|
|
203
|
+
payload.update(metadata or {})
|
|
204
|
+
self.track_event(
|
|
205
|
+
"STATE_TRANSITION" if previous_state else "STATE_ENTERED", payload, session_id
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# ── workflows ─────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
def start_workflow(self, workflow_name: str, session_id: Optional[str] = None) -> str:
|
|
211
|
+
workflow_id = str(uuid.uuid4())
|
|
212
|
+
with self._lock:
|
|
213
|
+
self._workflows[workflow_id] = {"name": workflow_name, "started": time.monotonic()}
|
|
214
|
+
self.track_event(
|
|
215
|
+
"WORKFLOW_STARTED", {"workflowId": workflow_id, "workflowName": workflow_name}, session_id
|
|
216
|
+
)
|
|
217
|
+
return workflow_id
|
|
218
|
+
|
|
219
|
+
def _finish_workflow(self, workflow_id: str) -> Optional[Dict[str, Any]]:
|
|
220
|
+
with self._lock:
|
|
221
|
+
record = self._workflows.pop(workflow_id, None)
|
|
222
|
+
if record is None:
|
|
223
|
+
return None
|
|
224
|
+
return {
|
|
225
|
+
"workflowName": record["name"],
|
|
226
|
+
"durationMs": round((time.monotonic() - record["started"]) * 1000, 3),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
def complete_workflow(self, workflow_id: str, session_id: Optional[str] = None) -> None:
|
|
230
|
+
result = self._finish_workflow(workflow_id)
|
|
231
|
+
if result:
|
|
232
|
+
self.track_event("WORKFLOW_COMPLETED", {"workflowId": workflow_id, **result}, session_id)
|
|
233
|
+
|
|
234
|
+
def fail_workflow(
|
|
235
|
+
self, workflow_id: str, reason: Optional[str] = None, session_id: Optional[str] = None
|
|
236
|
+
) -> None:
|
|
237
|
+
result = self._finish_workflow(workflow_id)
|
|
238
|
+
if result:
|
|
239
|
+
self.track_event(
|
|
240
|
+
"WORKFLOW_FAILED",
|
|
241
|
+
{"workflowId": workflow_id, "reason": reason or "Unknown error", **result},
|
|
242
|
+
session_id,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def cancel_workflow(
|
|
246
|
+
self, workflow_id: str, reason: Optional[str] = None, session_id: Optional[str] = None
|
|
247
|
+
) -> None:
|
|
248
|
+
result = self._finish_workflow(workflow_id)
|
|
249
|
+
if result:
|
|
250
|
+
self.track_event(
|
|
251
|
+
"WORKFLOW_CANCELLED",
|
|
252
|
+
{"workflowId": workflow_id, "reason": reason or "Cancelled", **result},
|
|
253
|
+
session_id,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def abandon_workflow(self, workflow_id: str) -> None:
|
|
257
|
+
with self._lock:
|
|
258
|
+
self._workflows.pop(workflow_id, None)
|
|
259
|
+
|
|
260
|
+
@contextlib.contextmanager
|
|
261
|
+
def workflow(self, workflow_name: str, session_id: Optional[str] = None) -> Iterator[str]:
|
|
262
|
+
"""Run a block as a workflow, failing it if the block raises."""
|
|
263
|
+
workflow_id = self.start_workflow(workflow_name, session_id)
|
|
264
|
+
try:
|
|
265
|
+
yield workflow_id
|
|
266
|
+
except BaseException as error:
|
|
267
|
+
self.fail_workflow(workflow_id, str(error), session_id)
|
|
268
|
+
raise
|
|
269
|
+
else:
|
|
270
|
+
self.complete_workflow(workflow_id, session_id)
|
|
271
|
+
|
|
272
|
+
# ── Flow checkpoints ──────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
def checkpoint(
|
|
275
|
+
self,
|
|
276
|
+
checkpoint_id: str,
|
|
277
|
+
*,
|
|
278
|
+
event_type: str = "FLOW_STATE_REACHED",
|
|
279
|
+
state_id: Optional[str] = None,
|
|
280
|
+
transition_id: Optional[str] = None,
|
|
281
|
+
terminal_kind: Optional[str] = None,
|
|
282
|
+
flow_initialization_id: Optional[str] = None,
|
|
283
|
+
session_id: Optional[str] = None,
|
|
284
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
285
|
+
) -> None:
|
|
286
|
+
"""Mark a declared Flow checkpoint.
|
|
287
|
+
|
|
288
|
+
This is what the instrumenter inserts into application code, so its
|
|
289
|
+
signature is a contract: changing it breaks every file already patched.
|
|
290
|
+
"""
|
|
291
|
+
payload: Dict[str, Any] = {"checkpointId": checkpoint_id}
|
|
292
|
+
if state_id:
|
|
293
|
+
payload["stateId"] = state_id
|
|
294
|
+
if transition_id:
|
|
295
|
+
payload["transitionId"] = transition_id
|
|
296
|
+
if terminal_kind:
|
|
297
|
+
payload["terminalKind"] = terminal_kind
|
|
298
|
+
if flow_initialization_id:
|
|
299
|
+
payload["flowInitializationId"] = flow_initialization_id
|
|
300
|
+
payload.update(metadata or {})
|
|
301
|
+
self.track_event(
|
|
302
|
+
event_type if event_type in EVENT_TYPES else "FLOW_STATE_REACHED", payload, session_id
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
#: The module-level singleton, matching the JavaScript SDK's `TELLANN` export.
|
|
307
|
+
TELLANN = TellannBackend()
|