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.
@@ -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
+ ```
@@ -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()