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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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)
@@ -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)