e-track 0.1.0__py3-none-any.whl
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.
- e_track-0.1.0.dist-info/METADATA +146 -0
- e_track-0.1.0.dist-info/RECORD +30 -0
- e_track-0.1.0.dist-info/WHEEL +4 -0
- e_track-0.1.0.dist-info/licenses/LICENSE +21 -0
- etrack/infra/config/base.py +69 -0
- etrack/infra/config/providers/adapters/bus/anyio.py +14 -0
- etrack/infra/config/providers/adapters/sink/live.py +17 -0
- etrack/infra/config/providers/drivers/sink/otlp.py +34 -0
- etrack/infra/errors.py +67 -0
- etrack/infra/logger.py +13 -0
- etrack/infra/proxy.py +24 -0
- etrack/infra/settings.py +70 -0
- etrack/logic/api.py +50 -0
- etrack/logic/channel.py +115 -0
- etrack/models/event.py +168 -0
- etrack/ops/file.py +145 -0
- etrack/ops/w3c.py +22 -0
- etrack/ports/bus.py +28 -0
- etrack/ports/provider.py +61 -0
- etrack/ports/sink.py +21 -0
- etrack/ports/source.py +54 -0
- etrack/ports/topology.py +50 -0
- etrack/processors/fold.py +167 -0
- etrack/processors/layout.py +102 -0
- etrack/providers/adapters/bus/anyio.py +161 -0
- etrack/providers/adapters/sink/live.py +141 -0
- etrack/providers/adapters/source/langgraph.py +428 -0
- etrack/providers/adapters/topology/langgraph.py +69 -0
- etrack/providers/drivers/sink/otlp.py +453 -0
- etrack/py.typed +0 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: e-track
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run tracking for agent systems: one event model carried by a structlog channel, many parallel sinks (OTLP, live).
|
|
5
|
+
Project-URL: Homepage, https://github.com/edamlabs/e-track
|
|
6
|
+
Project-URL: Documentation, https://edamlabs.github.io/e-track/
|
|
7
|
+
Project-URL: Source, https://github.com/edamlabs/e-track
|
|
8
|
+
Project-URL: Issues, https://github.com/edamlabs/e-track/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/edamlabs/e-track/blob/main/CHANGELOG.md
|
|
10
|
+
Author: EDamLabs
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: agents,e-stack,langgraph,opentelemetry,structlog,tracing
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Framework :: AsyncIO
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Classifier: Topic :: System :: Logging
|
|
22
|
+
Classifier: Topic :: System :: Monitoring
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.14
|
|
25
|
+
Requires-Dist: anyio>=4.6
|
|
26
|
+
Requires-Dist: msgspec>=0.21
|
|
27
|
+
Requires-Dist: pydantic-settings>=2.6
|
|
28
|
+
Requires-Dist: structlog>=26.1
|
|
29
|
+
Provides-Extra: all
|
|
30
|
+
Requires-Dist: e-serde>=0.6.2; extra == 'all'
|
|
31
|
+
Requires-Dist: langchain-core>=1.5.2; extra == 'all'
|
|
32
|
+
Requires-Dist: langgraph>=1.2.7; extra == 'all'
|
|
33
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.44; extra == 'all'
|
|
34
|
+
Requires-Dist: opentelemetry-sdk>=1.44; extra == 'all'
|
|
35
|
+
Requires-Dist: rustworkx>=0.17; extra == 'all'
|
|
36
|
+
Provides-Extra: langgraph
|
|
37
|
+
Requires-Dist: langchain-core>=1.5.2; extra == 'langgraph'
|
|
38
|
+
Requires-Dist: langgraph>=1.2.7; extra == 'langgraph'
|
|
39
|
+
Provides-Extra: otel
|
|
40
|
+
Requires-Dist: e-serde>=0.6.2; extra == 'otel'
|
|
41
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.44; extra == 'otel'
|
|
42
|
+
Requires-Dist: opentelemetry-sdk>=1.44; extra == 'otel'
|
|
43
|
+
Provides-Extra: topology
|
|
44
|
+
Requires-Dist: rustworkx>=0.17; extra == 'topology'
|
|
45
|
+
Description-Content-Type: text/markdown
|
|
46
|
+
|
|
47
|
+
<p align="center">
|
|
48
|
+
<a href="https://github.com/edamlabs/e-track/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/edamlabs/e-track/ci.yml?label=test" alt="CI"></a>
|
|
49
|
+
<a href="https://pypi.org/project/e-track/"><img src="https://img.shields.io/pypi/v/e-track?color=166b7e" alt="PyPI"></a>
|
|
50
|
+
<a href="https://pypi.org/project/e-track/"><img src="https://img.shields.io/pypi/pyversions/e-track" alt="Python"></a>
|
|
51
|
+
<a href="LICENSE"><img src="https://img.shields.io/github/license/edamlabs/e-track?color=166b7e" alt="License"></a>
|
|
52
|
+
</p>
|
|
53
|
+
|
|
54
|
+
<p align="center">
|
|
55
|
+
<img src="assets/logo.svg" alt="e-track" height="110" />
|
|
56
|
+
</p>
|
|
57
|
+
|
|
58
|
+
Run tracking for agent systems. A source turns a framework's run into **one event model**; the model travels on a
|
|
59
|
+
**structlog channel** of its own; a bus fans it out to **parallel sinks**, each reading the partial view it declares.
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
framework hooks ─► source ─► fold ─► channel (validate → publish → forward to host logger)
|
|
63
|
+
│
|
|
64
|
+
└─► bus ─┬─► otlp spans + logs → Langfuse · LangSmith · Tempo · Phoenix
|
|
65
|
+
├─► live per-thread replay + push → a canvas, a chat
|
|
66
|
+
└─► yours any SinkPort, any EventView
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Part of the **EDamLabs eager series**: EDamLabs' own e- products ([EDamLabs families](https://github.com/edamlabs/e-workbench#identity)).
|
|
70
|
+
|
|
71
|
+
## Install
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uv add "e-track[langgraph,otel]" # or: pip install "e-track[langgraph,otel]"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Extras: `langgraph` (LangGraph/deepagents source and topology), `otel` (OTLP driver), `topology` (layout), `all`.
|
|
78
|
+
Python 3.14+, typed (`py.typed`).
|
|
79
|
+
|
|
80
|
+
## Usage
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from contextlib import AsyncExitStack
|
|
84
|
+
|
|
85
|
+
from etrack.logic.api import Tracker
|
|
86
|
+
from etrack.logic.channel import EventChannel
|
|
87
|
+
from etrack.models.event import EventKind
|
|
88
|
+
from etrack.providers.adapters.bus.anyio import AnyioAdapter
|
|
89
|
+
from etrack.providers.adapters.sink.live import LiveAdapter
|
|
90
|
+
from etrack.providers.adapters.source.langgraph import LanggraphSourceAdapter
|
|
91
|
+
from etrack.providers.drivers.sink.otlp import OtlpDriver
|
|
92
|
+
|
|
93
|
+
async with AsyncExitStack() as stack: # the host's composition root
|
|
94
|
+
live = await stack.enter_async_context(LiveAdapter())
|
|
95
|
+
otlp = await stack.enter_async_context(OtlpDriver())
|
|
96
|
+
bus = AnyioAdapter()
|
|
97
|
+
bus.subscribe(live)
|
|
98
|
+
bus.subscribe(otlp, select=lambda event: event if event.part is None else None) # policy per sink
|
|
99
|
+
await stack.enter_async_context(bus) # opened last: closed first, drains into the sinks
|
|
100
|
+
tracker = Tracker(EventChannel(bus), LanggraphSourceAdapter(mcp=frozenset({"onto_query"})))
|
|
101
|
+
|
|
102
|
+
with tracker.track(thread="t1", config={"configurable": {"thread_id": "t1"}}) as run:
|
|
103
|
+
await graph.ainvoke({"messages": [("user", "hola")]}, run.config)
|
|
104
|
+
with run.fold.span("gate", kind=EventKind.EVALUATION):
|
|
105
|
+
... # the project's own code, same tree
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
With the house renderer (`ELogger`) on the host:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
2026-11-03T12:00:00Z | INFO | run.event || run_id: 019a… || kind: node || phase: start || name: agent || step: 1
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Every frame is also a line on the host's `etrack` logger, rendered by the host's own structlog setup; e-track never
|
|
115
|
+
configures logging.
|
|
116
|
+
|
|
117
|
+
## The record
|
|
118
|
+
|
|
119
|
+
| Field | What |
|
|
120
|
+
|---|---|
|
|
121
|
+
| `run_id`, `thread_id` | the run (32 hex, also the W3C trace id) and the conversation |
|
|
122
|
+
| `seq`, `id`, `parent` | order within the run; the span and its visible parent |
|
|
123
|
+
| `kind` | `run agent subagent node llm embedding retrieval tool mcp interrupt state evaluation custom` |
|
|
124
|
+
| `phase` | `start delta end error instant` |
|
|
125
|
+
| `part` | on deltas: `text reasoning tool_call image audio video file custom` |
|
|
126
|
+
| `node`, `ns`, `step`, `visit` | graph coordinates: node, subgraph path, superstep, times the run entered the node |
|
|
127
|
+
| `message_id`, `call_id` | the chat message and the tool call a client renders |
|
|
128
|
+
| `model`, `usage`, `error` | typed sections: provider/name/params, tokens/cost, failure |
|
|
129
|
+
| `payload`, `attributes` | what the span carried (redacted); the open, namespaced extension bag |
|
|
130
|
+
|
|
131
|
+
Grow it by subclassing `RunEvent` and passing the subclass to `EventChannel(model=…)`; read it through an `EventView`.
|
|
132
|
+
|
|
133
|
+
The full guide lives at [edamlabs.github.io/e-track](https://edamlabs.github.io/e-track/).
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
make install # sync deps + prek hooks
|
|
139
|
+
make check # lint + type + arch + validate + test
|
|
140
|
+
make cov # coverage gate: 100 % lines and branches, every outbound service faked in process
|
|
141
|
+
make integration # real services: the session fixtures bring sandbox/compose.yml up and down (collector + Phoenix)
|
|
142
|
+
make ci # every gate CI runs, in one command
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Stack: uv · ruff · ty · tach · pytest (xdist, randomly, pytest-docker) · prek · zizmor · Zensical · Renovate.
|
|
146
|
+
Released from a `v*` tag through PyPI trusted publishing; maintained by the [EDamLabs fleet](https://github.com/edamlabs/e-workbench).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
etrack/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
etrack/infra/errors.py,sha256=5y8598Gjr7QJRjE30D3nfPU99mtaGLnIPf6EnrbJbfo,2268
|
|
3
|
+
etrack/infra/logger.py,sha256=-kq4KOrgUf29v_IoDBe15pP4mJTmu1QVEMUGd-spar4,501
|
|
4
|
+
etrack/infra/proxy.py,sha256=pTtbMwNzAveoMcqOP7bRlR3ZUyPOG9ePjgBAZdBM0DQ,873
|
|
5
|
+
etrack/infra/settings.py,sha256=sdpNnX-mMBWFuCig3cBS6bQxuIB2haRNTyKh4QqW3Mc,3310
|
|
6
|
+
etrack/infra/config/base.py,sha256=9TDgJvFKICPVkAqjDFxEJ0mup9U-opYBVHrXliHqeTU,2752
|
|
7
|
+
etrack/infra/config/providers/adapters/bus/anyio.py,sha256=53-FWEBVlS9pbAEH1B8rua-Pu6xFEN0H3cjyQiD3ab0,514
|
|
8
|
+
etrack/infra/config/providers/adapters/sink/live.py,sha256=ucniO9_NuWwI9jrHTpy8gni635xeGtEYefAR7bVCvRQ,723
|
|
9
|
+
etrack/infra/config/providers/drivers/sink/otlp.py,sha256=BAqyYhsboqnAXcqp8YHVTvhXcJFnh8gbOtbV2cmIXXA,1409
|
|
10
|
+
etrack/logic/api.py,sha256=GcYq4gc6K2AIrVWsd7yookrfS4-5BEK_J7dSoJkS-Eg,2151
|
|
11
|
+
etrack/logic/channel.py,sha256=zZupoTi4fkKOAIH27cM3tmFBNkQSu7izTbAzIpjMG5c,4667
|
|
12
|
+
etrack/models/event.py,sha256=l_QbCQSh-_yeClykeSNbTgK1UX7FHBHCxqfJidVZyNA,5689
|
|
13
|
+
etrack/ops/file.py,sha256=1-u3J2ZFcp4bmecEZZF-frr23p_-neTnalVala4gbTM,6357
|
|
14
|
+
etrack/ops/w3c.py,sha256=mirEjgqnEqeGJm8FdV3dQ9x0ieCcKFyfHiNu1hL_j68,974
|
|
15
|
+
etrack/ports/bus.py,sha256=C8-ZYTPQfbVkjyr4KvwYqhA3VCAK7tnzuhdG9XDeNrw,1098
|
|
16
|
+
etrack/ports/provider.py,sha256=mLhLvvo8w2kPkJV1sVN6XwM4fQz15K5hMgCvjEFu9o8,1585
|
|
17
|
+
etrack/ports/sink.py,sha256=vyliF9wSukcvSP4yWG76KGE_799H_truBAaCB8jKoek,843
|
|
18
|
+
etrack/ports/source.py,sha256=kRwu6AommvEsitz7UYMXHzrndg1jUdYqi2aAK75vdgc,2106
|
|
19
|
+
etrack/ports/topology.py,sha256=Bgup0N5qlpVVVX0WmQjR_rKvuGWDiox_11mEAEps3GQ,1486
|
|
20
|
+
etrack/processors/fold.py,sha256=zwK2kRDdXXL0YVBCYJ15TNV3E4shd3F-9D1JnKGC9uo,6772
|
|
21
|
+
etrack/processors/layout.py,sha256=os_kFac4dlpn2y1vvPFRTjhdGFD5OrJb8Um6_MaPYPs,4189
|
|
22
|
+
etrack/providers/adapters/bus/anyio.py,sha256=0StV8PTYXzFslRRp9Kwh4idlpAaBTvTp-hk6EV4HaQ8,7015
|
|
23
|
+
etrack/providers/adapters/sink/live.py,sha256=zu_BfS8jfmV3439Gq_caLTS1Hkyb5Um5RbXuLC4ST0k,5853
|
|
24
|
+
etrack/providers/adapters/source/langgraph.py,sha256=Ya3Jwbx8vkOtYqqSkRTQBWtnae5eG7VObMyDZccACTc,18430
|
|
25
|
+
etrack/providers/adapters/topology/langgraph.py,sha256=5CRW4pjLPvpjTfvm5_RRKrIb8Dr6-uRUKgWkkNPrS34,2653
|
|
26
|
+
etrack/providers/drivers/sink/otlp.py,sha256=DT3NrlDhDYeBD9HbVQhhJcn90U7Ux2ROMyiwNZa9CvE,19480
|
|
27
|
+
e_track-0.1.0.dist-info/METADATA,sha256=qw-tPWTyb4XkRxokW6KSh7PdaLnjfdmo8iEs8sQq3gA,7218
|
|
28
|
+
e_track-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
29
|
+
e_track-0.1.0.dist-info/licenses/LICENSE,sha256=5qq3BchfBcDiZc2C600p0BtrFHNr_TD9JsmYyltgd0U,1065
|
|
30
|
+
e_track-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EDamLabs
|
|
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,69 @@
|
|
|
1
|
+
"""infra/config/base: pydantic-settings — frozen, typed, layered config.
|
|
2
|
+
|
|
3
|
+
This module wires the five layers every scope shares: config file < secrets < .env < environment <
|
|
4
|
+
kwargs. `Secret` is pydantic's `SecretStr`: masked through repr, str and f-strings, readable only
|
|
5
|
+
via `.get_secret_value()`. All sub-settings inherit from here."""
|
|
6
|
+
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
from pydantic import SecretStr
|
|
10
|
+
from pydantic_settings import (
|
|
11
|
+
BaseSettings as SettingsBase,
|
|
12
|
+
)
|
|
13
|
+
from pydantic_settings import (
|
|
14
|
+
JsonConfigSettingsSource,
|
|
15
|
+
PydanticBaseSettingsSource,
|
|
16
|
+
SettingsConfigDict,
|
|
17
|
+
TomlConfigSettingsSource,
|
|
18
|
+
YamlConfigSettingsSource,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
Secret = SecretStr
|
|
22
|
+
|
|
23
|
+
FILE_SOURCES: Final = (
|
|
24
|
+
("toml_file", TomlConfigSettingsSource),
|
|
25
|
+
("yaml_file", YamlConfigSettingsSource),
|
|
26
|
+
("json_file", JsonConfigSettingsSource),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BaseSettings(SettingsBase):
|
|
31
|
+
"""Subclass and declare typed fields; the constructor merges every layer in one pass.
|
|
32
|
+
|
|
33
|
+
A scope narrows its own sources through ``model_config``: ``env_prefix``, ``env_file``,
|
|
34
|
+
``toml_file`` / ``yaml_file`` / ``json_file``, ``secrets_dir`` (set ``/run/secrets`` there when
|
|
35
|
+
it is containerized — an absent default directory warns). Complex values in env or .env are
|
|
36
|
+
JSON-decoded; enums, paths and timedeltas validate on load, never at runtime."""
|
|
37
|
+
|
|
38
|
+
model_config = SettingsConfigDict(
|
|
39
|
+
frozen=True,
|
|
40
|
+
### every scope reads the one shared .env: each takes its own keys and ignores the others' — forbidding
|
|
41
|
+
### extras would make any foreign key (REDIS_URL, SECRET...) abort every other scope at load.
|
|
42
|
+
extra="ignore",
|
|
43
|
+
env_file=".env",
|
|
44
|
+
env_nested_delimiter="__",
|
|
45
|
+
nested_model_default_partial_update=True,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def settings_customise_sources(
|
|
50
|
+
cls,
|
|
51
|
+
settings_cls: type[SettingsBase],
|
|
52
|
+
init_settings: PydanticBaseSettingsSource,
|
|
53
|
+
env_settings: PydanticBaseSettingsSource,
|
|
54
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
55
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
56
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
57
|
+
### pydantic-settings reads these first-to-highest: kwargs beat env, the config file is the floor.
|
|
58
|
+
return (
|
|
59
|
+
init_settings,
|
|
60
|
+
env_settings,
|
|
61
|
+
dotenv_settings,
|
|
62
|
+
file_secret_settings,
|
|
63
|
+
*cls._settings_customise_files(settings_cls),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _settings_customise_files(settings_cls: type[SettingsBase]) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
68
|
+
"""The configured config-file sources only: absent format, absent source."""
|
|
69
|
+
return tuple(source(settings_cls) for key, source in FILE_SOURCES if settings_cls.model_config.get(key))
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""infra/config/providers/adapters/bus/anyio: how many events may wait for each sink before the next is dropped."""
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from pydantic_settings import SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
from etrack.infra.config.base import BaseSettings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AnyioSettings(BaseSettings):
|
|
10
|
+
"""ETRACK_BUS_CAPACITY — events queued per sink; past it, a slow sink loses the newest and the drop is counted."""
|
|
11
|
+
|
|
12
|
+
model_config = SettingsConfigDict(env_prefix="ETRACK_BUS_")
|
|
13
|
+
|
|
14
|
+
capacity: int = Field(default=1000, ge=1)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""infra/config/providers/adapters/sink/live: how much of each thread the live buffer keeps, for how many threads."""
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from pydantic_settings import SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
from etrack.infra.config.base import BaseSettings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LiveSettings(BaseSettings):
|
|
10
|
+
"""ETRACK_LIVE_BUFFER — frames kept per thread for a reconnect to replay; ETRACK_LIVE_THREADS — threads kept, the
|
|
11
|
+
least recently written evicted first; ETRACK_LIVE_BACKLOG — frames a subscriber may lag before it is cut off."""
|
|
12
|
+
|
|
13
|
+
model_config = SettingsConfigDict(env_prefix="ETRACK_LIVE_")
|
|
14
|
+
|
|
15
|
+
buffer: int = Field(default=2000, ge=1)
|
|
16
|
+
threads: int = Field(default=256, ge=1)
|
|
17
|
+
backlog: int = Field(default=1000, ge=1)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""infra/config/providers/drivers/sink/otlp: where spans and logs go over OTLP/HTTP, how fast, under which service."""
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
from pydantic_settings import SettingsConfigDict
|
|
7
|
+
|
|
8
|
+
from etrack.infra.config.base import BaseSettings, Secret
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OtlpTarget(BaseModel):
|
|
12
|
+
"""One collector: its OTLP/HTTP base URL (signals under `/v1/traces`, `/v1/logs`) and the headers it authenticates."""
|
|
13
|
+
|
|
14
|
+
model_config = ConfigDict(frozen=True)
|
|
15
|
+
|
|
16
|
+
endpoint: str
|
|
17
|
+
headers: dict[str, Secret] = Field(default_factory=dict)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OtlpSettings(BaseSettings):
|
|
21
|
+
"""ETRACK_OTLP_TARGETS — collectors by name as JSON (`{"langfuse": {"endpoint": …, "headers": {…}}}`; none: spans
|
|
22
|
+
are minted, never exported); ETRACK_OTLP_TRACES / ETRACK_OTLP_LOGS — which signals leave; ETRACK_OTLP_TIMEOUT —
|
|
23
|
+
per-export deadline; ETRACK_OTLP_FLUSH — how often a batch leaves; ETRACK_OTLP_SERVICE — `service.name`;
|
|
24
|
+
ETRACK_OTLP_ATTRIBUTES — extra resource attributes as JSON."""
|
|
25
|
+
|
|
26
|
+
model_config = SettingsConfigDict(env_prefix="ETRACK_OTLP_")
|
|
27
|
+
|
|
28
|
+
targets: dict[str, OtlpTarget] = Field(default_factory=dict)
|
|
29
|
+
traces: bool = True
|
|
30
|
+
logs: bool = False
|
|
31
|
+
timeout: timedelta = timedelta(seconds=10)
|
|
32
|
+
flush: timedelta = timedelta(seconds=1)
|
|
33
|
+
service: str = "etrack"
|
|
34
|
+
attributes: dict[str, str] = Field(default_factory=dict)
|
etrack/infra/errors.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""infra/errors: the one root of every error — each layer imports its exceptions from here, none defines its own.
|
|
2
|
+
|
|
3
|
+
`CoreError` is the root. One branch per kind of module: `ProviderError` (a provider failed at its border), `ProcessorError`,
|
|
4
|
+
`LogicError` and `OpsError`. A provider fails as a **driver** (`DriverError`: a system outside the process) or as an
|
|
5
|
+
**adapter** (`AdapterError`: a library inside it). A sink is met on both natures — the OTLP exporter over the wire,
|
|
6
|
+
the live buffer in memory — so `SinkError` is honestly both."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
##### ROOT #####
|
|
10
|
+
class CoreError(Exception):
|
|
11
|
+
"""All etrack exceptions inherit from this root."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.message = message
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
##### PROVIDERS #####
|
|
19
|
+
class ProviderError(CoreError):
|
|
20
|
+
"""A provider failed at its border: what a third-party library raised, or a call on a provider that is not open."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DriverError(ProviderError):
|
|
24
|
+
"""A system outside the process failed: unreachable, refusing, slow, or answering outside its contract."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AdapterError(ProviderError):
|
|
28
|
+
"""A library inside the process failed: it refused its input, or the provider it wraps is not open."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SinkError(DriverError, AdapterError):
|
|
32
|
+
"""A sink that is not open, or refused what it was sent."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BusError(AdapterError):
|
|
36
|
+
"""The bus is not open, or was asked to deliver through a closed stream."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SourceError(AdapterError):
|
|
40
|
+
"""A source that cannot bind a run: the host's config is not of the shape its framework takes."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
##### PROCESSORS #####
|
|
44
|
+
class ProcessorError(CoreError):
|
|
45
|
+
"""A transformation refused its input."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LayoutError(ProcessorError, ValueError):
|
|
49
|
+
"""A topology that cannot be placed: an edge to a node it does not declare."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
##### LOGIC #####
|
|
53
|
+
class LogicError(CoreError):
|
|
54
|
+
"""The library's core refused a call."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ChannelError(LogicError, ValueError):
|
|
58
|
+
"""An event the channel cannot validate against its model: a reserved key missing or of the wrong type."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
##### OPS #####
|
|
62
|
+
class OpsError(CoreError):
|
|
63
|
+
"""An agnostic tool refused its input."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FileError(OpsError, TypeError):
|
|
67
|
+
"""A discovery target of a kind the finder does not sweep."""
|
etrack/infra/logger.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""infra/logger: the library's own diagnostics — structlog's lazy logger, configured by the host, never by etrack.
|
|
2
|
+
|
|
3
|
+
A library never calls `structlog.configure`: the application owns rendering, level and destination. `logger` is the
|
|
4
|
+
named, lazy logger every etrack module writes its lifecycle and degraded paths to; its lines land wherever the host's
|
|
5
|
+
configuration sends them."""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Final
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
|
|
11
|
+
NAME: Final[str] = "etrack"
|
|
12
|
+
|
|
13
|
+
logger: Any = structlog.get_logger(NAME)
|
etrack/infra/proxy.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""infra/proxy: import-safe lazy singletons — the object exists at module level, its load runs on first use."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
##### TYPES #####
|
|
11
|
+
class LazyProxy[T]:
|
|
12
|
+
"""Attribute access delegated to a `T` built exactly once per process, on first access.
|
|
13
|
+
|
|
14
|
+
The module body only constructs the proxy — no env, no secrets, no `.env`, no structlog at
|
|
15
|
+
import. `functools.cache` pins the build so the second touch reuses the first instance."""
|
|
16
|
+
|
|
17
|
+
__slots__ = ("_get",)
|
|
18
|
+
|
|
19
|
+
def __init__(self, build: Callable[[], T]) -> None:
|
|
20
|
+
self._get = functools.cache(build)
|
|
21
|
+
|
|
22
|
+
def __getattr__(self, name: str) -> Any:
|
|
23
|
+
### only reached for names absent on the proxy itself: `_get` is a slot, so no recursion here.
|
|
24
|
+
return getattr(self._get(), name)
|
etrack/infra/settings.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""infra/settings: the dynamic composer — every ``*Settings`` of ``infra/config``, merged into one nested ``Settings``.
|
|
2
|
+
|
|
3
|
+
The config tree mirrors the module tree: ``infra/config/providers/drivers/database/psqlpy.py`` configures
|
|
4
|
+
``providers/drivers/database/psqlpy.py``, and its settings are read at ``st.providers.drivers.database.psqlpy`` — the
|
|
5
|
+
module path, the attribute chain and the module configured are one hierarchy. Scopes that serve no provider, engine or
|
|
6
|
+
processor sit at the top (``st.app.env``). Each scope keeps its own sources (``env_prefix``, ``env_file``,
|
|
7
|
+
``toml_file``, ``secrets_dir``), so adding a scope is just dropping a file in ``infra/config`` — ``base`` is the only
|
|
8
|
+
thing skipped. Consumers import the lazy singleton: ``from etrack.infra.settings import settings as st`` →
|
|
9
|
+
``st.crypto.alg`` (env, secrets and config files are read on that first access, never at import)."""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any, cast
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, create_model
|
|
15
|
+
|
|
16
|
+
from etrack.infra.config.base import BaseSettings, Secret
|
|
17
|
+
from etrack.infra.proxy import LazyProxy
|
|
18
|
+
from etrack.ops.file import FileFinder
|
|
19
|
+
|
|
20
|
+
__all__ = ["BaseSettings", "Secret", "Settings", "settings"]
|
|
21
|
+
|
|
22
|
+
type Paths = dict[tuple[str, ...], type[BaseSettings]]
|
|
23
|
+
|
|
24
|
+
_SETTINGS_RE = re.compile(r"^([A-Z]\w*)Settings$")
|
|
25
|
+
_CONFIG = f"{__package__}.config"
|
|
26
|
+
### derived from the package, never spelled: materialization rewrites imports, not strings (`etrack.infra` → `infra`).
|
|
27
|
+
_SCOPES: Paths = {
|
|
28
|
+
tuple(cls.__module__.removeprefix(f"{_CONFIG}.").split(".")): cls
|
|
29
|
+
for cls in FileFinder(_CONFIG, skip=frozenset({"base"}), recursive=True)
|
|
30
|
+
.discover(BaseSettings, pattern=_SETTINGS_RE)
|
|
31
|
+
.values()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _grow(name: str, scopes: Paths) -> type[BaseModel]:
|
|
36
|
+
"""The model of a branch: one field per first path segment, a scope itself where the path ends there."""
|
|
37
|
+
heads = sorted({path[0] for path in scopes})
|
|
38
|
+
below = {head: {path[1:]: cls for path, cls in scopes.items() if path[0] == head} for head in heads}
|
|
39
|
+
fields: dict[str, Any] = {
|
|
40
|
+
head: ((rest[()] if () in rest else _grow(f"{name}{head.title()}", rest)), ...) for head, rest in below.items()
|
|
41
|
+
}
|
|
42
|
+
return create_model(name, __config__=ConfigDict(frozen=True), **fields)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
Settings: Any = _grow("Settings", _SCOPES)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _build(model: type[BaseModel], overrides: dict[str, Any]) -> Any:
|
|
49
|
+
"""A branch built from its children, a scope from its own layers; an override dict reaches the scope it names."""
|
|
50
|
+
return (
|
|
51
|
+
model(**overrides)
|
|
52
|
+
if issubclass(model, BaseSettings)
|
|
53
|
+
else model(
|
|
54
|
+
**{
|
|
55
|
+
name: _build(cast("type[BaseModel]", field.annotation), overrides.get(name, {}))
|
|
56
|
+
for name, field in model.model_fields.items()
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _load(cls: Any, **overrides: Any) -> Any:
|
|
63
|
+
"""Build the composite: every scope reads its own layers; an override dict nested like the tree beats them."""
|
|
64
|
+
return _build(cls, overrides)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
### the parent constructor cannot honour per-scope sources or per-scope overrides: each scope loads itself.
|
|
68
|
+
Settings.load = classmethod(_load)
|
|
69
|
+
### no I/O at import: env, secrets and config files are read on the first attribute access, once per process.
|
|
70
|
+
settings: LazyProxy[Any] = LazyProxy(lambda: _load(Settings))
|
etrack/logic/api.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""logic/api: Tracker — the public surface: one run tracked from a host's config, its frames on the channel.
|
|
2
|
+
|
|
3
|
+
`track(thread, config)` opens a run: a fresh `RunScope` (uuid7, the trace id too), a fold whose frames go to the
|
|
4
|
+
channel, the source's hook bound into the host's config, and `run_id`/`thread_id` bound in structlog's contextvars
|
|
5
|
+
for every log line of the block. The host runs its framework with the config it gets back; the `Tracked` it holds also
|
|
6
|
+
reaches the fold for spans and facts of its own code (`tracked.fold.span(…)`, `tracked.fold.instant(…)`)."""
|
|
7
|
+
|
|
8
|
+
import uuid
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import structlog
|
|
14
|
+
|
|
15
|
+
from etrack.models.event import RunScope
|
|
16
|
+
from etrack.processors.fold import FoldProcessor, Redact
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Generator
|
|
20
|
+
|
|
21
|
+
from etrack.logic.channel import EventChannel
|
|
22
|
+
from etrack.ports.source import SourcePort
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class Tracked[C]:
|
|
27
|
+
"""A run in progress: its scope, the host's config with the source bound, and the fold behind it."""
|
|
28
|
+
|
|
29
|
+
scope: RunScope
|
|
30
|
+
config: C
|
|
31
|
+
fold: FoldProcessor
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Tracker[C]:
|
|
35
|
+
"""Runs of one framework, tracked onto one channel; `redact` is the project's mask over every payload."""
|
|
36
|
+
|
|
37
|
+
__slots__ = ("_channel", "_redact", "_source")
|
|
38
|
+
|
|
39
|
+
def __init__(self, channel: EventChannel, source: SourcePort[C], redact: Redact | None = None) -> None:
|
|
40
|
+
self._channel = channel
|
|
41
|
+
self._source = source
|
|
42
|
+
self._redact = redact
|
|
43
|
+
|
|
44
|
+
@contextmanager
|
|
45
|
+
def track(self, thread: str, config: C, run_id: str | None = None) -> Generator[Tracked[C]]:
|
|
46
|
+
"""One run on `thread`: the scope, the bound config and the fold, with the run's ids bound for the block."""
|
|
47
|
+
scope = RunScope(run_id=run_id or uuid.uuid7().hex, thread_id=thread)
|
|
48
|
+
fold = FoldProcessor(scope, self._channel.emit, self._redact)
|
|
49
|
+
with structlog.contextvars.bound_contextvars(run_id=scope.run_id, thread_id=scope.thread_id):
|
|
50
|
+
yield Tracked(scope=scope, config=self._source.bind(fold, scope, config), fold=fold)
|