flowtraicer 0.9.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.
- flowtraicer-0.9.0.dist-info/METADATA +467 -0
- flowtraicer-0.9.0.dist-info/RECORD +36 -0
- flowtraicer-0.9.0.dist-info/WHEEL +4 -0
- flowtraicer-0.9.0.dist-info/entry_points.txt +2 -0
- flowtraicer-0.9.0.dist-info/licenses/LICENSE +201 -0
- ft/__init__.py +3 -0
- ft/analytics.py +112 -0
- ft/audit.py +31 -0
- ft/core/__init__.py +1 -0
- ft/core/model.py +178 -0
- ft/examples/__init__.py +1 -0
- ft/examples/demo_agent.py +126 -0
- ft/extraction.py +126 -0
- ft/langgraph_adapter/__init__.py +7 -0
- ft/langgraph_adapter/runner.py +154 -0
- ft/langgraph_adapter/state.py +36 -0
- ft/langgraph_adapter/topology.py +42 -0
- ft/llm.py +168 -0
- ft/orchestration.py +235 -0
- ft/py.typed +0 -0
- ft/recorder.py +197 -0
- ft/registry.py +150 -0
- ft/retention.py +33 -0
- ft/server/__init__.py +1 -0
- ft/server/app.py +96 -0
- ft/server/static/app.js +228 -0
- ft/server/static/index.html +50 -0
- ft/server/static/style.css +140 -0
- ft/store/__init__.py +1 -0
- ft/store/base.py +76 -0
- ft/store/postgres.py +117 -0
- ft/store/reconstruct.py +74 -0
- ft/store/records.py +109 -0
- ft/store/redis.py +121 -0
- ft/store/sqlite.py +113 -0
- ft/timeline.py +111 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flowtraicer
|
|
3
|
+
Version: 0.9.0
|
|
4
|
+
Summary: Map, visualize, monitor, debug, log and audit the steps of an engagement between a user and an agentic AI system.
|
|
5
|
+
Project-URL: Homepage, https://github.com/AlirezaShk/flowtraicer
|
|
6
|
+
Project-URL: Repository, https://github.com/AlirezaShk/flowtraicer
|
|
7
|
+
Project-URL: Issues, https://github.com/AlirezaShk/flowtraicer/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/AlirezaShk/flowtraicer/blob/main/CHANGELOG.md
|
|
9
|
+
Author: TeranoAI
|
|
10
|
+
License: Apache-2.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agents,audit,langgraph,llm,observability,tracing
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Topic :: System :: Monitoring
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Requires-Dist: fastapi>=0.110
|
|
25
|
+
Requires-Dist: langchain-core>=0.3
|
|
26
|
+
Requires-Dist: langgraph>=0.2
|
|
27
|
+
Requires-Dist: pydantic>=2.7
|
|
28
|
+
Requires-Dist: uvicorn>=0.29
|
|
29
|
+
Provides-Extra: anthropic
|
|
30
|
+
Requires-Dist: anthropic>=0.40; extra == 'anthropic'
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: fakeredis>=2.20; extra == 'dev'
|
|
33
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
37
|
+
Provides-Extra: extraction
|
|
38
|
+
Requires-Dist: instructor>=1.0; extra == 'extraction'
|
|
39
|
+
Provides-Extra: google
|
|
40
|
+
Requires-Dist: google-genai>=1.0; extra == 'google'
|
|
41
|
+
Provides-Extra: litellm
|
|
42
|
+
Requires-Dist: litellm>=1.40; extra == 'litellm'
|
|
43
|
+
Provides-Extra: openai
|
|
44
|
+
Requires-Dist: openai>=1.0; extra == 'openai'
|
|
45
|
+
Provides-Extra: postgres
|
|
46
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'postgres'
|
|
47
|
+
Provides-Extra: providers
|
|
48
|
+
Requires-Dist: anthropic>=0.40; extra == 'providers'
|
|
49
|
+
Requires-Dist: google-genai>=1.0; extra == 'providers'
|
|
50
|
+
Requires-Dist: openai>=1.0; extra == 'providers'
|
|
51
|
+
Provides-Extra: redis
|
|
52
|
+
Requires-Dist: redis>=5; extra == 'redis'
|
|
53
|
+
Description-Content-Type: text/markdown
|
|
54
|
+
|
|
55
|
+
# FlowTraicer
|
|
56
|
+
|
|
57
|
+
## From the Human Author
|
|
58
|
+
|
|
59
|
+
I couldn't find an intuitive package that JUST works when it comes to visualizing, monitoring, auditing, debugging, and orchestrating agentic workflows in a systematic way. So I just decided to build one with Claude. Feel free to use this, and contribute to it if you feel like it!
|
|
60
|
+
|
|
61
|
+
## Short Introduction
|
|
62
|
+
|
|
63
|
+
An open source Python library to **map, visualize, monitor, debug, log, and audit** the steps of an engagement between a user and an agentic AI system.
|
|
64
|
+
|
|
65
|
+
You build your agent as a [LangGraph](https://github.com/langchain-ai/langgraph) graph;
|
|
66
|
+
`FlowTraicer` captures each run as a structured, append-only trace and renders it as a linked
|
|
67
|
+
**graph + timeline** in a browser.
|
|
68
|
+
|
|
69
|
+
## The model
|
|
70
|
+
|
|
71
|
+
An engagement is a three-level tree:
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
Engagement → Step (one workflow node) → Events (llm_call / tool_call / extraction / log / error)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- **Engagement** — one whole user↔agent session. Carries free-form `metadata` (put your
|
|
78
|
+
`user_id`, `session_id`, etc. here).
|
|
79
|
+
- **Step** — one LangGraph node execution. Has a status, timing, the **tools available** to
|
|
80
|
+
it, and an optional **per-step extraction** (a Pydantic schema pulled from the turn).
|
|
81
|
+
- **Event** — something that happened inside a step (a tool call, an LLM call, a log line,
|
|
82
|
+
an error, an extraction).
|
|
83
|
+
- **Global step** — a node that can fire from anywhere and re-route the workflow's intent
|
|
84
|
+
(e.g. "escalate to a human"). Entering one records an **intent switch**.
|
|
85
|
+
|
|
86
|
+
## Install
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install -e ".[dev]" # core + test deps
|
|
90
|
+
pip install -e ".[extraction,openai]" # + Instructor extraction with the OpenAI SDK
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
(Requires Python ≥ 3.11. The package imports as `FlowTraicer` regardless of the `src/` layout.)
|
|
94
|
+
|
|
95
|
+
## Getting started: instrument a LangGraph agent
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
import asyncio
|
|
99
|
+
from typing import Annotated, TypedDict
|
|
100
|
+
from operator import add
|
|
101
|
+
|
|
102
|
+
from langgraph.graph import StateGraph, START, END
|
|
103
|
+
|
|
104
|
+
from ft.store.sqlite import SQLiteStore
|
|
105
|
+
from ft.recorder import Recorder
|
|
106
|
+
from ft.langgraph_adapter import run_instrumented
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# 1. Build your agent as a normal LangGraph graph. Extend TraceState so you don't
|
|
110
|
+
# redeclare FlowTraicer's channels (tool_calls / llm_calls / events / extraction) yourself.
|
|
111
|
+
from ft.langgraph_adapter import TraceState
|
|
112
|
+
|
|
113
|
+
class State(TraceState):
|
|
114
|
+
messages: Annotated[list, add] # your own domain fields
|
|
115
|
+
|
|
116
|
+
def greet(state): return {"messages": ["hi, what are you looking for?"]}
|
|
117
|
+
def search(state): return {"messages": ["here are 3 options"],
|
|
118
|
+
"tool_calls": [{"name": "search_db", "payload": {"hits": 3}}]}
|
|
119
|
+
|
|
120
|
+
g = StateGraph(State)
|
|
121
|
+
g.add_node("greet", greet)
|
|
122
|
+
g.add_node("search", search)
|
|
123
|
+
g.add_edge(START, "greet")
|
|
124
|
+
g.add_edge("greet", "search")
|
|
125
|
+
g.add_edge("search", END)
|
|
126
|
+
app = g.compile()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# 2. Pick a store. SQLite is the zero-dependency default; pass a path to persist.
|
|
130
|
+
store = SQLiteStore("traces.db")
|
|
131
|
+
recorder = Recorder(store)
|
|
132
|
+
|
|
133
|
+
# 3. Run it under instrumentation. Everything the run does is recorded.
|
|
134
|
+
engagement_id = asyncio.run(run_instrumented(
|
|
135
|
+
app,
|
|
136
|
+
{"messages": [], "tool_calls": []},
|
|
137
|
+
recorder,
|
|
138
|
+
name="house_search",
|
|
139
|
+
metadata={"user_id": "u-42", "session_id": "s-1"}, # tag the journey
|
|
140
|
+
global_nodes={"escalate"}, # nodes that re-route intent
|
|
141
|
+
node_tools={"search": ["search_db"]}, # tools available per step
|
|
142
|
+
))
|
|
143
|
+
|
|
144
|
+
# 4. Read the trace back.
|
|
145
|
+
engagement = store.get_engagement(engagement_id)
|
|
146
|
+
for step in engagement.steps:
|
|
147
|
+
print(step.name, step.status, f"{step.duration_ms:.1f}ms",
|
|
148
|
+
[e.name for e in step.events])
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Enriching a step from inside a node
|
|
152
|
+
|
|
153
|
+
`run_instrumented` records node entry/exit, timing, and intent switches automatically. To
|
|
154
|
+
capture more, a node may write these **conventional state keys** — the runner records
|
|
155
|
+
whatever it finds, so nodes never need a handle to the recorder:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
def qualify(state):
|
|
159
|
+
return {
|
|
160
|
+
"messages": ["got it"],
|
|
161
|
+
# tools the node called:
|
|
162
|
+
"tool_calls": [{"name": "lookup_area", "payload": {"area": "Shibuya"}}],
|
|
163
|
+
# LLM calls with token cost (rolls up into step.total_tokens / engagement.total_tokens):
|
|
164
|
+
"llm_calls": [{"name": "gpt-4o-mini", "prompt_tokens": 64, "completion_tokens": 20}],
|
|
165
|
+
# any other typed event (kind = llm_call|tool_call|extraction|log|error):
|
|
166
|
+
"events": [{"kind": "log", "name": "cache_hit", "payload": {"key": "shibuya"}}],
|
|
167
|
+
# a per-step structured extraction:
|
|
168
|
+
"extraction": {"schema_name": "BudgetInfo", "values": {"budget": 95000}},
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`State` must declare these keys (use `Annotated[list, add]` for the list-valued ones).
|
|
173
|
+
Outside a graph you can also call the recorder directly:
|
|
174
|
+
`recorder.record_llm_call(step_id, "gpt-4o", prompt=64, completion=20)`.
|
|
175
|
+
|
|
176
|
+
### Drop-off: goals & abandonment
|
|
177
|
+
|
|
178
|
+
Pass `goal_nodes` to mark journeys that never reached a goal as **abandoned** (instead of
|
|
179
|
+
the indistinguishable `completed`), with `dropped_at` set to the last step reached:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
await run_instrumented(app, state, recorder,
|
|
183
|
+
goal_nodes={"submitted"}) # didn't reach it -> ABANDONED
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Free-form LLM calls — bring your own provider
|
|
187
|
+
|
|
188
|
+
FlowTraicer's core only *records* tokens; **it never imports a specific LLM SDK.** The single
|
|
189
|
+
integration point is the `LLMClient` protocol (`ft.llm.LLMClient`): one async method returning the
|
|
190
|
+
completion text and its token usage.
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from ft.llm import LLMClient # a runtime_checkable Protocol
|
|
194
|
+
|
|
195
|
+
class LLMClient(Protocol):
|
|
196
|
+
async def acomplete(self, messages, **overrides) -> LLMResult: ... # text + token usage
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
When a node calls `await ctx.llm(prompt)`, the workflow calls `acomplete` on whatever you passed as
|
|
200
|
+
`llm=` to `Workflow.run(...)`, then records the returned tokens. So you can plug in **any** provider
|
|
201
|
+
or SDK — there's no LiteLLM requirement.
|
|
202
|
+
|
|
203
|
+
**The bundled option** is `LiteLLMClient`, which wraps [LiteLLM](https://docs.litellm.ai) so one
|
|
204
|
+
config targets 100+ providers (install the `litellm` extra):
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
from ft.llm import LiteLLMClient
|
|
208
|
+
|
|
209
|
+
llm = LiteLLMClient(provider="openai", model="gpt-5-nano", api_key="XXX")
|
|
210
|
+
# or from a config blob: LiteLLMClient.from_config({"llm_provider": "openai", "model": "...", "key": "..."})
|
|
211
|
+
engagement_id = await workflow.run(state, recorder, llm=llm)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
#### Adding your own provider / SDK
|
|
215
|
+
|
|
216
|
+
Implement `acomplete` and return an `LLMResult` (reuse it — `TokenUsage` derives `total` from
|
|
217
|
+
`prompt + completion` when you don't set it, and `as_llm_call()` is handled for you):
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
from ft.core.model import TokenUsage
|
|
221
|
+
from ft.llm import LLMResult # any object with `.text` + `.as_llm_call()` works; LLMResult is the easy path
|
|
222
|
+
|
|
223
|
+
class MyGeminiClient:
|
|
224
|
+
"""Adapt your existing SDK/client to FlowTraicer. `acomplete` is the only method ctx.llm needs."""
|
|
225
|
+
def __init__(self, sdk):
|
|
226
|
+
self._sdk = sdk
|
|
227
|
+
|
|
228
|
+
async def acomplete(self, messages, **overrides) -> LLMResult:
|
|
229
|
+
resp = await self._sdk.generate(messages, **overrides) # however your SDK returns text + usage
|
|
230
|
+
return LLMResult(
|
|
231
|
+
text=resp.text,
|
|
232
|
+
tokens=TokenUsage(prompt=resp.usage.input, completion=resp.usage.output),
|
|
233
|
+
model=resp.model,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# isinstance(MyGeminiClient(sdk), LLMClient) is True (structural check), and:
|
|
237
|
+
await workflow.run(state, recorder, llm=MyGeminiClient(sdk)) # tokens flow into every step's trace
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Notes:
|
|
241
|
+
- **Sync SDK?** Wrap the blocking call so you don't stall the event loop:
|
|
242
|
+
`return await anyio.to_thread.run_sync(lambda: self._complete(messages))`.
|
|
243
|
+
- **Capture usage.** The whole point of `ctx.llm` is token accounting — read your SDK's usage
|
|
244
|
+
field (e.g. Gemini's `response.usage_metadata`, OpenAI's `response.usage`) into `TokenUsage`.
|
|
245
|
+
- **Per-call overrides.** `ctx.llm(prompt, model="…", temperature=0)` forwards `**overrides` to
|
|
246
|
+
`acomplete`; honor what you support and ignore the rest.
|
|
247
|
+
|
|
248
|
+
#### Setting a global default provider
|
|
249
|
+
|
|
250
|
+
Passing `llm=` to every `run()` gets repetitive. Register one **global default** instead — every
|
|
251
|
+
workflow falls back to it when no per-run / per-workflow client is given:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
from ft.llm import LiteLLMClient
|
|
255
|
+
from ft.registry import REGISTER
|
|
256
|
+
|
|
257
|
+
# LiteLLM is the bundled default provider; register a configured instance once at startup:
|
|
258
|
+
REGISTER.set_llm_provider(LiteLLMClient(provider="openai", model="gpt-5-nano", api_key=KEY))
|
|
259
|
+
# ...or your own client — set_llm_provider VALIDATES it satisfies LLMClient first:
|
|
260
|
+
REGISTER.set_llm_provider(MyGeminiClient(sdk)) # TypeError if it has no async acomplete()
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Resolution order when a node calls `ctx.llm`:
|
|
264
|
+
|
|
265
|
+
```text
|
|
266
|
+
run(llm=…) > Workflow(llm=…) > REGISTER.get_llm_provider()
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
So the global is the lowest-priority fallback: a per-run `llm=` (e.g. a request-scoped client)
|
|
270
|
+
always wins. `REGISTER.set_llm_provider` asserts the client exposes a callable, async
|
|
271
|
+
`acomplete(messages, **overrides)` and raises a descriptive `TypeError` otherwise — you can't
|
|
272
|
+
register a provider that won't work at run time.
|
|
273
|
+
|
|
274
|
+
#### Token usage for LLM calls made *outside* a workflow
|
|
275
|
+
|
|
276
|
+
Not every LLM call runs inside a `Workflow` — chat replies, voice turns, and one-off extractions
|
|
277
|
+
often call a model directly. Register a global recorder and push their token usage into FlowTraicer
|
|
278
|
+
so it rolls up alongside everything else:
|
|
279
|
+
|
|
280
|
+
```python
|
|
281
|
+
from ft.core.model import TokenUsage
|
|
282
|
+
from ft.recorder import Recorder
|
|
283
|
+
from ft.registry import REGISTER
|
|
284
|
+
from ft.store.postgres import PostgresStore
|
|
285
|
+
|
|
286
|
+
REGISTER.set_recorder(Recorder(PostgresStore(DSN))) # validated on registration
|
|
287
|
+
|
|
288
|
+
# wherever you make a model call (e.g. inside an SDK adapter), after you have the usage:
|
|
289
|
+
REGISTER.record_llm_usage(
|
|
290
|
+
"openai/gpt-5-nano",
|
|
291
|
+
tokens=TokenUsage(prompt=resp.usage.prompt_tokens, completion=resp.usage.completion_tokens),
|
|
292
|
+
caller="instagram_dm.classifier", # shows up in the viewer / group_by
|
|
293
|
+
metadata={"session_id": session_id},
|
|
294
|
+
)
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Each call becomes a small self-contained engagement (one `llm_call` event), so per-model /
|
|
298
|
+
per-caller token totals appear in the viewer and `analytics.group_by`. It's **fail-open**: with no
|
|
299
|
+
recorder registered it's a no-op, and a recorder error never propagates into the calling agent.
|
|
300
|
+
|
|
301
|
+
(Structured extraction uses Instructor; see below.)
|
|
302
|
+
|
|
303
|
+
### Per-step schema extraction (Instructor + Pydantic)
|
|
304
|
+
|
|
305
|
+
```python
|
|
306
|
+
from pydantic import BaseModel
|
|
307
|
+
from ft.extraction import Extractor
|
|
308
|
+
|
|
309
|
+
class BudgetInfo(BaseModel):
|
|
310
|
+
budget: int
|
|
311
|
+
area: str
|
|
312
|
+
|
|
313
|
+
extractor = Extractor.from_provider("openai/gpt-4o-mini") # or anthropic/… , google/…
|
|
314
|
+
result = extractor.extract(BudgetInfo, "Shibuya, around ¥95,000")
|
|
315
|
+
|
|
316
|
+
# inside a node — record-via-state (the runner picks it up):
|
|
317
|
+
return {"extraction": result.as_record().model_dump()}
|
|
318
|
+
# anywhere else — record directly:
|
|
319
|
+
extractor.extract_and_record(recorder, step_id, BudgetInfo, "Shibuya, around ¥95,000")
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
## Declarative workflows (the DSL)
|
|
323
|
+
|
|
324
|
+
`ft.orchestration.Workflow` is sugar over LangGraph: declare steps (with tools), global
|
|
325
|
+
steps, goals, and edges once — it compiles the graph and wires per-step tools / global nodes
|
|
326
|
+
/ goal nodes into the recorder, so there's no separate bookkeeping to pass to the runner.
|
|
327
|
+
|
|
328
|
+
```python
|
|
329
|
+
from ft.orchestration import Workflow
|
|
330
|
+
|
|
331
|
+
wf = Workflow("school_journey", state_schema=State, goal_nodes={"submit"})
|
|
332
|
+
|
|
333
|
+
@wf.step(tools=["search_schools"])
|
|
334
|
+
def school_selection(state): ...
|
|
335
|
+
|
|
336
|
+
@wf.global_step # entering it records an intent switch
|
|
337
|
+
def escalate(state): ...
|
|
338
|
+
|
|
339
|
+
wf.entry("intake")
|
|
340
|
+
wf.edge("intake", "school_selection")
|
|
341
|
+
wf.branch("school_selection", router, {"compare": "comparison", "apply": "consent"})
|
|
342
|
+
wf.finish("submit")
|
|
343
|
+
|
|
344
|
+
engagement_id = await wf.run(initial_state, recorder, metadata={"user_id": "u1"})
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
(The hand-wired `run_instrumented` approach still works; the DSL is optional sugar.)
|
|
348
|
+
|
|
349
|
+
## Storage backends
|
|
350
|
+
|
|
351
|
+
The `Store` is pluggable (append-only: write a record, reconstruct an engagement, list
|
|
352
|
+
summaries, subscribe to a live tail). Pick by environment — the trace, viewer, and analytics
|
|
353
|
+
work identically on all three:
|
|
354
|
+
|
|
355
|
+
```python
|
|
356
|
+
from ft.store.sqlite import SQLiteStore # default; zero deps, file or :memory:
|
|
357
|
+
store = SQLiteStore("traces.db")
|
|
358
|
+
|
|
359
|
+
from ft.store.redis import RedisStore # pip install -e ".[redis]"
|
|
360
|
+
store = RedisStore("redis://localhost:6379") # Redis Streams; live tail across processes
|
|
361
|
+
|
|
362
|
+
from ft.store.postgres import PostgresStore # pip install -e ".[postgres]"
|
|
363
|
+
store = PostgresStore("postgresql://localhost/FlowTraicer") # durable JSONB + LISTEN/NOTIFY
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
- **SQLite** — local dev, single process, audit-friendly append-only file.
|
|
367
|
+
- **Redis** — cross-process live monitoring (recorder and viewer can be separate services).
|
|
368
|
+
- **Postgres** — durable + queryable for production, with `LISTEN/NOTIFY` live updates.
|
|
369
|
+
|
|
370
|
+
## Audit & retention
|
|
371
|
+
|
|
372
|
+
```python
|
|
373
|
+
from datetime import timedelta
|
|
374
|
+
from ft.retention import RetentionPolicy, purge_before
|
|
375
|
+
from ft.audit import engagement_digest, verify
|
|
376
|
+
|
|
377
|
+
# retention — drop whole completed engagements past their window (active ones never purged)
|
|
378
|
+
purged_ids = RetentionPolicy(max_age=timedelta(days=90)).apply(store, now=...)
|
|
379
|
+
# or: purge_before(store, cutoff_datetime)
|
|
380
|
+
|
|
381
|
+
# tamper-evidence — fingerprint an engagement; later, detect any alteration
|
|
382
|
+
digest = engagement_digest(store.get_engagement(eid)) # store this when the engagement ends
|
|
383
|
+
verify(store.get_engagement(eid), digest) # -> False if the trail was altered
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Anchor the digest outside the trace store (WORM / transparency log / signature) for strong
|
|
387
|
+
tamper-evidence — see `FlowTraicer/audit.py` for the threat model.
|
|
388
|
+
|
|
389
|
+
## Analytics: funnels & journeys
|
|
390
|
+
|
|
391
|
+
Across many engagements (tag each with `user_id`/`session_id` in `metadata`), answer
|
|
392
|
+
*"where do users drop off, and what did each step cost?"*:
|
|
393
|
+
|
|
394
|
+
```python
|
|
395
|
+
from ft.analytics import funnel, journeys, group_by
|
|
396
|
+
|
|
397
|
+
f = funnel(store, ["intake", "school_selection", "comparison", "application", "submitted"])
|
|
398
|
+
for step in f.steps:
|
|
399
|
+
print(step.name, "reached", step.reached, "dropped", step.dropped,
|
|
400
|
+
"conv", step.conversion_rate, "tokens", step.total_tokens,
|
|
401
|
+
"avg_ms", step.avg_duration_ms)
|
|
402
|
+
|
|
403
|
+
journeys(store, user_id="u-42") # all engagements for one user (filtered summaries)
|
|
404
|
+
group_by(store, "user_id") # {user_id: [summary, ...]}
|
|
405
|
+
store.list_engagements(where={"session_id": "s-1"}) # metadata-filtered index
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Drop-off is read from each engagement's recorded `dropped_at`, so the funnel stays correct
|
|
409
|
+
even when `order` contains an **optional** step (e.g. `comparison`) — `conversion_rate` is
|
|
410
|
+
always in `[0, 1]`.
|
|
411
|
+
|
|
412
|
+
## Viewing traces
|
|
413
|
+
|
|
414
|
+
The viewer is a FastAPI app + a Cytoscape.js single page (graph on top, timeline below,
|
|
415
|
+
linked). To explore the bundled demo:
|
|
416
|
+
|
|
417
|
+
```bash
|
|
418
|
+
python -m ft.server.app # http://127.0.0.1:8400
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
To view **your own** store, build the app around it:
|
|
422
|
+
|
|
423
|
+
```python
|
|
424
|
+
import uvicorn
|
|
425
|
+
from ft.server.app import create_app
|
|
426
|
+
|
|
427
|
+
uvicorn.run(create_app(SQLiteStore("traces.db")), host="127.0.0.1", port=8400)
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
The API behind the viewer:
|
|
431
|
+
|
|
432
|
+
| Endpoint | Returns |
|
|
433
|
+
|---|---|
|
|
434
|
+
| `GET /api/engagements` | one summary row per engagement |
|
|
435
|
+
| `GET /api/engagements/{id}` | the full engagement tree (+ topology) |
|
|
436
|
+
| `GET /api/engagements/{id}/timeline` | the temporal viewmodel (lanes, marks) |
|
|
437
|
+
| `WS /api/stream` | records pushed live as they're appended |
|
|
438
|
+
|
|
439
|
+
## Architecture
|
|
440
|
+
|
|
441
|
+
| Module | Responsibility |
|
|
442
|
+
|---|---|
|
|
443
|
+
| `ft.core.model` | the Pydantic data model (framework-agnostic) |
|
|
444
|
+
| `ft.store` | append-only `Store` protocol + SQLite default backend |
|
|
445
|
+
| `ft.recorder` | the fail-open emit contract |
|
|
446
|
+
| `ft.langgraph_adapter` | `run_instrumented` + `read_topology` |
|
|
447
|
+
| `ft.orchestration` | `Workflow` DSL — declare steps/global-steps/tools/goals over LangGraph |
|
|
448
|
+
| `ft.extraction` | Instructor-powered per-step schema extraction |
|
|
449
|
+
| `ft.llm` | config-driven multi-provider LLM calls (LiteLLM) |
|
|
450
|
+
| `ft.analytics` | cross-engagement funnels, drop-off, journey grouping |
|
|
451
|
+
| `ft.retention` / `ft.audit` | purge old engagements; tamper-evident digests |
|
|
452
|
+
| `ft.timeline` | temporal viewmodel for the timeline view |
|
|
453
|
+
| `ft.server` | FastAPI query + live-stream API and the viewer |
|
|
454
|
+
|
|
455
|
+
The trace **core knows nothing about LangGraph** — the adapter feeds it through the
|
|
456
|
+
recorder's small emit API, so other engines can be added as adapters later.
|
|
457
|
+
|
|
458
|
+
## Status & roadmap
|
|
459
|
+
|
|
460
|
+
Done: trace core + SQLite/Redis/Postgres stores, LangGraph auto-instrumentation + the
|
|
461
|
+
`Workflow` DSL, Instructor extraction, config-driven LLM (LiteLLM), **per-step token cost**,
|
|
462
|
+
**goals/abandonment + drop-off analytics**, retention + tamper-evident audit, and the linked
|
|
463
|
+
graph/timeline viewer. Planned: OSS packaging. See [`docs/`](docs/).
|
|
464
|
+
|
|
465
|
+
## License
|
|
466
|
+
|
|
467
|
+
Apache-2.0 (intended; final license confirmed at open-source time).
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
ft/__init__.py,sha256=qUrDzk92gLmGc9JvkAR5avQR_ipI69Ms0K5-9p2f5-A,115
|
|
2
|
+
ft/analytics.py,sha256=Mpck5KVzvcR3BoLHZYVzMVtmVkPEfGOZbj4RkQTvY7Y,4395
|
|
3
|
+
ft/audit.py,sha256=mqwiG_anwDcaT-j9ZotzSh8o2MyPb4YS-SBi4htsZEA,1325
|
|
4
|
+
ft/extraction.py,sha256=dq36JMv9fSzhPqcFMWpo65SmmonTp00ev0y-D2nXHhM,4416
|
|
5
|
+
ft/llm.py,sha256=jRpE7CDR37vTXJCTdskvUj7V3nLBnb7C19L3SK0Zuqg,6660
|
|
6
|
+
ft/orchestration.py,sha256=thkUbsWW4eWqv3gxPvisdmqdIm1KUwEJ9wlifAxtMXo,9272
|
|
7
|
+
ft/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
ft/recorder.py,sha256=lK42Rg5oaMBOCLf9Jf1QGUUA0nMmwmPX8UJO6tzDEkA,5924
|
|
9
|
+
ft/registry.py,sha256=cqVsbacosyeeNkFrDxz0dRnv83l-jOLZFbwKdM82lBM,6098
|
|
10
|
+
ft/retention.py,sha256=2J1SF1EkbraaUOZ5wZ0n0kOWL2qKWi-kck-Jkto7fWQ,1248
|
|
11
|
+
ft/timeline.py,sha256=bWcK51Miz90UqpTMRbyeKD0xH5_9vbcPuGgqgrPpfew,3462
|
|
12
|
+
ft/core/__init__.py,sha256=kqJgOMttZuK-BOSSMr31vVp1W2u2rW7vqGrzNxO2mag,53
|
|
13
|
+
ft/core/model.py,sha256=7us47Hc2Wg6JAq83IrwpgZiz8DcBAy62e6kKZExRltI,5050
|
|
14
|
+
ft/examples/__init__.py,sha256=QYjNJLbHwFqFuK73o2LhOd01sOcvdspsyUeMyjSZqDQ,79
|
|
15
|
+
ft/examples/demo_agent.py,sha256=3Ps0xrudDGw5uWJ8KDUX7BwSUhqAgIMFysQvGy2ZZYY,4228
|
|
16
|
+
ft/langgraph_adapter/__init__.py,sha256=8Z1-z50-MKlpt2TXzwPHQHVHPL2iSvtV2RgmDsTZsLk,252
|
|
17
|
+
ft/langgraph_adapter/runner.py,sha256=sSp-ypt_LI9gMrdiP_oRpzZDZear-cXI18n3g6xaXWY,6374
|
|
18
|
+
ft/langgraph_adapter/state.py,sha256=fWPaHXruHBfxylaEAwlYEP97au-Gmecd0MWMj6kV4sM,1480
|
|
19
|
+
ft/langgraph_adapter/topology.py,sha256=QB4p-3tf1DyL8DOGG5M3MG8cnkGyaiq9EwQ3Hu6Dv_4,1319
|
|
20
|
+
ft/server/__init__.py,sha256=h0sCdoKHP_7HaInZmZcfm_nst10bLW78BYLRRtgxf0Y,61
|
|
21
|
+
ft/server/app.py,sha256=NfbfQJkkYeLd-pFtpSmQZiktOkafGoR1dGgcWlaIbUY,3306
|
|
22
|
+
ft/server/static/app.js,sha256=SeO9txxdY4LVsirnT8hzFQgj1wf_Ykjf9OsnSmfPFyU,8730
|
|
23
|
+
ft/server/static/index.html,sha256=WU3WTD-MRbc8nyVvzIb_li20T66-GmG42S3edzD830I,1500
|
|
24
|
+
ft/server/static/style.css,sha256=JZin8ZQUlvJ51cGgbQLbpkmYStLits98E2RILiFFPO4,5684
|
|
25
|
+
ft/store/__init__.py,sha256=btEGrL3rcM0hsFSgyZ83JOgBdPd-YoL0bOsMTdRHlaA,70
|
|
26
|
+
ft/store/base.py,sha256=KAWlhChNiqXsIeBx5rs0b2cbCNpcjKb45xg-6lBuV08,2518
|
|
27
|
+
ft/store/postgres.py,sha256=ODPEFigjswtpK4wJTAZv4JARsu57FyewWIVC-H8oYA4,4890
|
|
28
|
+
ft/store/reconstruct.py,sha256=IMOUF21OWWWeQ_bvcoNqRX2ZanrBYW9Es8SsnhMmeyc,2455
|
|
29
|
+
ft/store/records.py,sha256=czyjNTJmeaRWexvvEwCQZGLBatlgYL3F1KwgdrHdAf0,2959
|
|
30
|
+
ft/store/redis.py,sha256=6njivSHWZPKr7U6NUMri6Rt2DfrekZbupviI0LlhTtU,4776
|
|
31
|
+
ft/store/sqlite.py,sha256=jLPq--Sn04mu7xcAZiiaVhbytmxWmotHdNzzy28u09g,4447
|
|
32
|
+
flowtraicer-0.9.0.dist-info/METADATA,sha256=FRYZVQi65yv8oPDWDmfD8Kp89_99ftGX64H76cTPQ9k,18993
|
|
33
|
+
flowtraicer-0.9.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
34
|
+
flowtraicer-0.9.0.dist-info/entry_points.txt,sha256=GpNtA3K4LrPJtFF7wNNVAaQ9Q9zeEKbOGfcHHFl1AsM,49
|
|
35
|
+
flowtraicer-0.9.0.dist-info/licenses/LICENSE,sha256=M1nqEbO93gnUfwN0DiV83O-7GgFhh4y3kqpYY-XYMmk,11357
|
|
36
|
+
flowtraicer-0.9.0.dist-info/RECORD,,
|