Loading the notebook…
Loading the notebook…
Notebooks · Workspace, live UI and observability · 79
One agent run, reported five ways: traces, the agent graph, scores, datasets and the local ledger.
Its imports and arguments are checked against shipit-agent 3.0. The outputs were saved when it was written, for an earlier release.
shipit-watcher is not on PyPI yet, so this installs it from the
sibling checkout and puts both repos ahead of any older installed copy —
otherwise Jupyter imports the released shipit_agent and WatcherExporter
is missing.
# Setup — run once, BEFORE anything else.
#
# Order matters: the repo checkouts go on sys.path first, so an older
# `shipit_agent` already installed in this kernel cannot win the import.
# Importing it before adjusting the path caches the wrong copy, and
# WatcherExporter then appears to be missing.
import subprocess
import sys
import pathlib
repo = pathlib.Path.cwd().parent # .../shipit_agent
watcher = repo.parent / "shipit-watcher"
for path in (str(watcher), str(repo)):
if path not in sys.path:
sys.path.insert(0, path)
# langfuse is a real dependency; the two repos are used from source.
try:
import langfuse
except ImportError:
subprocess.run([sys.executable, "-m", "pip", "install", "-q",
"langfuse==2.60.10"], check=True)
if not watcher.exists():
# No sibling checkout — fall back to the published package.
subprocess.run([sys.executable, "-m", "pip", "install", "-q",
"shipit-watcher"], check=True)
import shipit_agent
import shipit_watcher
from shipit_agent.tracing_exporters import WatcherExporter
print(f"shipit-agent {shipit_agent.__version__}")
print(f" {shipit_agent.__file__}")
print(f"shipit-watcher {pathlib.Path(shipit_watcher.__file__).parent}")
print("WatcherExporter: ready")shipit-agent 1.6.0
~/Documents/MYWORK/ai_developer/others/shipit_agent/shipit_agent/__init__.py
shipit-watcher ~/Documents/MYWORK/ai_developer/others/shipit-watcher/shipit_watcher
WatcherExporter: readyThree variables carry everything — traces, scores, datasets and prompts all use the same client. There is one URL: the OTLP endpoint is derived, not configured separately.
import os
import pathlib
# Load .env without a dependency.
for line in pathlib.Path("../.env").read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip())
from shipit_watcher.config import WatcherConfig
c = WatcherConfig()
print(f"host : {c.langfuse_host}")
print(f"public key : {'set' if c.langfuse_public_key else 'MISSING'}")
print(f"secret key : {'set' if c.langfuse_secret_key else 'MISSING'}")
print(f"transport : {c.langfuse_transport}")
print(f"service/env : {c.service_name} / {c.environment}")
print(f"masking : {c.mask_pii} bodies sent: {c.capture_content}")
print(f"governance : {c.governance_mode}")
print(f"local ledger: {c.persist_to_database} -> {c.ledger_model}")
print()
print(f"credentials ok: {c.has_langfuse_credentials} emitting: {c.is_active}")host : https://c0b633aca3ca.ngrok-free.app public key : set secret key : set transport : otlp service/env : shipit-agent / development masking : True bodies sent: True governance : audit local ledger: False -> agent.LLMCallRecord credentials ok: True emitting: True
is_active is False when credentials are missing — an unconfigured
deployment emits nothing rather than erroring. That is why this notebook is
safe to run before you have keys.
from shipit_watcher.sinks.langfuse_otel_sink import LangfuseOTLPSink
sink = LangfuseOTLPSink()
print("OTLP endpoint :", sink._endpoint or "(no host configured)")
print("auth header :", "set" if sink._auth else "MISSING")
print("available :", sink.available)OTLP endpoint : https://c0b633aca3ca.ngrok-free.app/api/public/otel/v1/traces auth header : set available : True
The whole integration.
from shipit_agent import Agent
exporter = WatcherExporter()
# agent = Agent(llm=llm, tools=tools, trace_store=exporter)
print(type(exporter).__name__, "is a TraceStore:",
hasattr(exporter, "append_event") and hasattr(exporter, "load"))WatcherExporter is a TraceStore: True
A spy sink, so you can see the translation without a server.
from shipit_watcher.tracer import Tracer
from shipit_agent.llms.base import LLMResponse
from shipit_agent.models import ToolCall
from shipit_agent.tools.base import ToolOutput
seen = []
class SpySink:
def start_trace(self, trace_id, name, context, input_data=None):
seen.append(("trace opened", name))
def end_trace(self, trace_id, output=None, metadata=None):
seen.append(("trace closed", ""))
def record(self, event, context):
seen.append((event.type.value, event.name))
def record_score(self, score):
seen.append(("score", f"{score.name}={score.value}"))
def flush(self):
pass
class ScriptedLLM:
model = "demo"
def __init__(self): self.n = 0
def complete(self, **kw):
self.n += 1
if self.n == 1:
return LLMResponse(content="Reading the config first.",
tool_calls=[ToolCall(name="read_file",
arguments={"path": "app.py"})])
return LLMResponse(content="app.py sets DEBUG=True.")
class ReadFile:
name, description, prompt_instructions = "read_file", "Read a file", ""
def schema(self):
return {"function": {"name": "read_file", "parameters":
{"properties": {"path": {"type": "string"}}}}}
def run(self, context, **kw):
return ToolOutput(text="DEBUG = True",
metadata={"summary": "12 bytes"})
result = Agent(
llm=ScriptedLLM(), tools=[ReadFile()],
trace_store=WatcherExporter(tracer=Tracer(sinks=[SpySink()])),
progress_summaries=True, auto_use_skills=False, max_iterations=4,
).run("what does app.py set?")
for kind, name in seen:
print(f" {kind:16} {name}")trace opened agent.run decision agent.decision tool_invocation tool.read_file decision agent.decision trace closed
tool_invocation is what Langfuse renders as a node in the agent graph
— that is the payoff of WATCHER_LANGFUSE_TRANSPORT=otlp. On sdk every
observation is a generic span and the graph is a flat list.
Notice the narration alongside it:
for event in result.events:
if event.type in ("agent_decision", "agent_observation"):
print(f" {event.type:18} {event.payload.get('summary')}")agent_decision Reading the config first. agent_observation Read app.py — 12 bytes. agent_decision app.py sets DEBUG=True.
Those cost no extra model call — the decision is the model's own sentence, and the observation reads the summary the tool declared about itself.
Needs credentials. flush() matters: the exporter buffers, and a
short-lived process can exit before the batch is sent.
if c.is_active:
live = WatcherExporter()
Agent(llm=ScriptedLLM(), tools=[ReadFile()], trace_store=live,
progress_summaries=True, auto_use_skills=False,
max_iterations=4).run("what does app.py set?")
live.flush()
print("sent — open", c.langfuse_host, "and look for service =", c.service_name)
else:
print("skipped: no credentials. Fill LANGFUSE_* in .env")watcher: otlp export failed 500: b'{"message":"Internal Server Error","error":"MISCONF Redis is configured to save RDB snapshots, but it\'s currently unable to persist to disk. Commands that may modify the data set are disabled, because this instance is configured to report errors during writes if RDB snapshotting fails (stop-writes-o'sent — open https://c0b633aca3ca.ngrok-free.app and look for service = shipit-agent
A score can arrive long after the trace closed — a thumbs-up an hour later — so it goes over the REST API rather than riding along with the export. That is why scoring works on both transports.
if c.is_active:
from shipit_watcher import Score, record_score
record_score(Score(name="helpfulness", value=0.9,
comment="answered from the file, no guessing"))
print("score sent")
else:
print("skipped: no credentials")API error occurred: Internal server error occurred. For help, please contact support: https://langfuse.com/support
score sent
capture() at the point of failure is the cheapest way to build an eval set:
the bad case is written down when you see it, not reconstructed later.
if c.is_active:
from shipit_watcher import capture, create_dataset
create_dataset("agent-regressions", description="cases that went wrong")
capture("agent-regressions",
input="what does app.py set?",
expected_output="DEBUG = True")
print("captured")
else:
print("skipped: no credentials")captured
Langfuse is where you look at traces. The ledger is where they are kept: retention outlives any hosted plan, the data never leaves your boundary, and cost-per-tenant is a SQL query rather than an export from someone else's UI.
It is off unless the host application provides the models:
WATCHER_PERSIST_DB=1
WATCHER_LEDGER_MODEL=agents.LLMCallRecord
WATCHER_LEDGER_EVENT_MODEL=agents.TraceEventRecord
The labels are configurable because the sink used to import one hard-coded path — so in any other Django project the ImportError was swallowed and you got an empty ledger that looked like an idle one.
print(f"persist_to_database : {c.persist_to_database}")
print(f"ledger model : {c.ledger_model}")
print(f"event model : {c.ledger_event_model}")
if not c.persist_to_database:
print("\n-> nothing is written locally. If the tunnel drops, those runs are gone.")persist_to_database : False ledger model : agent.LLMCallRecord event model : agent.TraceEventRecord -> nothing is written locally. If the tunnel drops, those runs are gone.
config resolves from .env | cell 1 |
| OTLP endpoint derived from one URL | cell 1 |
| agent run reaches watcher's sinks | cell 3 |
| narration costs no extra model call | cell 3 |
| traces / scores / datasets reach Langfuse | cells 4–6, needs your server |
| local ledger | not enabled — needs models in the host app |