Lumenova Beacon SDK
A Python observability SDK for AI/LLM applications — trace agentic frameworks (LangChain, LangGraph, CrewAI, Strands), LLM calls, and custom code with OpenTelemetry-compatible spans.
Features
- LangChain/LangGraph Integration - Automatic tracing for chains, agents, tools, retrievers, with interrupt/resume and agent handoff support
- Strands Agents Integration - Hook provider (recommended) or legacy callback handler for AWS Strands agent tracing
- CrewAI Integration - Event listener for CrewAI crew tracing
- MCP Server Integration - FastMCP middleware that traces an MCP server (tools, resources, prompts) independently of any agent
- LiteLLM Integration - Callback logger for LiteLLM proxy tracing
- Agentic Governance - Real-time policy enforcement for AI agent tool calls and LLM invocations
- System Probes - Run autonomous AI agents that probe your HTTP system locally (private APIs, custom auth) and produce scored markdown reports
- OpenTelemetry Integration - Automatic instrumentation for Anthropic, OpenAI, FastAPI, Redis, HTTPX, and more
- Manual & Decorator Tracing - Create spans manually or use
@tracedecorator - Trace Querying & Export - Query, search, and filter the traces you sent to Beacon — and download complete traces back out for archiving or offline analysis
- Agent Registry & Insights - Register the agents traces are attributed to, read Beacon's AI-generated insights and recommendations, and trigger/monitor analysis runs
- Dataset Management - ActiveRecord-style API for managing test datasets
- Prompt Management - Version-controlled prompt templates with labels (staging, production)
- Experiment & Evaluation Management - Run experiments over datasets and evaluate results
- Human Annotations - Enqueue traces/spans/sessions for human review, push external feedback, read annotation summaries back
- Data Masking - Deterministic PII floor plus Beacon Guardrails detection, applied to every exported span
- Span Noise Control - Drop the spans you don't want (name globs, ASGI plumbing, orphan background work) before they leave the process
- Flexible Transport - HTTP or file-based span export
- Full Async Support - Async/await throughout
Requirements
- Python 3.10+
Installation
# Base installation
pip install lumenova-beacon
# With OpenTelemetry support
pip install lumenova-beacon[opentelemetry]
# With LangChain/LangGraph support
pip install lumenova-beacon[langchain]
# With LiteLLM support
pip install lumenova-beacon[litellm]
# With Strands Agents support
pip install lumenova-beacon[strands]
# With CrewAI support
pip install lumenova-beacon[crewai]
# With MCP server support (FastMCP)
pip install lumenova-beacon[mcp]
# With AWS Secrets Manager support
pip install lumenova-beacon[aws]
Quick Start
LangChain / LangGraph
from lumenova_beacon import BeaconClient, BeaconLangGraphHandler
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
# Initialize client
client = BeaconClient(
endpoint="https://your-beacon-endpoint.lumenova.ai",
api_key="your-api-key",
)
# Create a tracing handler
handler = BeaconLangGraphHandler(session_id="session-123")
# All LangChain operations are now traced automatically
llm = ChatOpenAI(model="gpt-4")
prompt = ChatPromptTemplate.from_template("Tell me about {topic}")
chain = prompt | llm
response = chain.invoke(
{"topic": "AI agents"},
config={"callbacks": [handler]}
)
Basic Tracing
from lumenova_beacon import BeaconClient, trace
client = BeaconClient(
endpoint="https://your-beacon-endpoint.lumenova.ai",
api_key="your-api-key",
session_id="my-session"
)
@trace
def my_function(x, y):
return x + y
result = my_function(10, 20) # Automatically traced
No endpoint? Pass
file_directory='./traces'instead ofendpointto write spans as JSON locally — useful for development and tests. See File Transport.
Configuration
Environment Variables
All environment variables work as fallback — constructor parameters override them:
| Variable | Purpose | Default |
|---|---|---|
BEACON_ENDPOINT |
API base URL for OTLP export | (required unless using file_directory) |
BEACON_API_KEY |
Authentication token | |
BEACON_ENABLED |
Master off switch for tracing. false/0/no/off installs no TracerProvider, builds no exporters, and makes span export a no-op. See Turning tracing off. |
true |
BEACON_SESSION_ID |
Default session ID for spans | |
BEACON_PROJECT_ID |
Target project id, stamped on every span as the beacon.project_id attribute. Required with workspace API keys; wins over BEACON_PROJECT_ALIAS. |
|
BEACON_PROJECT_ALIAS |
Target project alias (external id), stamped on every span as the beacon.project_alias attribute. Ignored when a project id is set. |
|
BEACON_SERVICE_NAME |
Service name for OTEL resource (fallback: OTEL_SERVICE_NAME) |
|
BEACON_ENVIRONMENT |
Deployment environment (e.g., "production", "staging") | |
BEACON_USER_EMAIL |
Default user email for attribution | |
BEACON_USER_NAME |
Default user display name for attribution | |
BEACON_USER_ID |
Default user ID for attribution | |
BEACON_VERIFY |
TLS verification for all SDK HTTP calls. true/false or a CA bundle path. |
true |
BEACON_CLIENT_CERT |
mTLS client cert path (combined PEM, or cert file paired with BEACON_CLIENT_KEY). |
|
BEACON_CLIENT_KEY |
mTLS client key path, paired with BEACON_CLIENT_CERT. |
|
BEACON_PROXIES |
HTTP(S) proxy for all SDK calls. URL string or JSON dict ({"http":..., "https":...}). Bypass via NO_PROXY. Use empty-string values ({"http":"","https":""}) to disable proxies for those schemes and override any http_proxy / https_proxy env vars; null is rejected. |
|
BEACON_EAGER_EXPORT |
Eager span export: true sends every span at start (end_time=0, upserted on completion), root sends only trace roots at start so the trace appears immediately without doubling span traffic, false sends spans only on completion. |
true |
BEACON_ISOLATED |
Use a private TracerProvider when another OTEL exporter is already running | false |
BEACON_OTEL_CONTEXT_PROPAGATION |
Bridge native Beacon spans with the ambient OpenTelemetry context (adopt OTel parents + nest instrumentor spans under native spans). | true unless isolated |
BEACON_INBOUND_CONTEXT |
What to do with an inbound W3C traceparent: honor, ignore, or trusted (adopt only Beacon peers' context). Set behind Cloud Run / GCLB / ELB. |
honor |
BEACON_EXTRA_OTLP_ENDPOINTS |
Additional OTLP endpoints (comma-separated). Port 4318 is HTTP; others are gRPC. |
|
BEACON_AWS_SECRET_NAME |
AWS Secrets Manager secret name (resolves API key) | |
BEACON_AWS_SECRET_KEY |
Key path within AWS secret | api_key |
BEACON_AWS_REGION |
AWS region for Secrets Manager | (boto3 default) |
BEACON_VERIFY, BEACON_CLIENT_CERT/_KEY, and BEACON_PROXIES apply to the HTTP OTLP exporter and REST API calls. For gRPC OTLP extras, use grpc_proxy/https_proxy and OTEL_EXPORTER_OTLP_CERTIFICATE/_CLIENT_CERTIFICATE/_CLIENT_KEY.
# Bash/Linux/macOS
export BEACON_ENDPOINT="https://your-beacon-endpoint.lumenova.ai"
export BEACON_API_KEY="your-api-key"
export BEACON_SESSION_ID="my-session"
# PowerShell
$env:BEACON_ENDPOINT = "https://your-beacon-endpoint.lumenova.ai"
$env:BEACON_API_KEY = "your-api-key"
$env:BEACON_SESSION_ID = "my-session"
Configuration Options
from lumenova_beacon import BeaconClient
client = BeaconClient(
# Connection
endpoint="https://your-beacon-endpoint.lumenova.ai",
api_key="your-api-key",
verify=True, # Or a CA bundle path, e.g. "/path/to/corp-ca.pem"
# proxies="http://proxy.example.com:8080", # Or a dict: {"http": "...", "https": "..."}. Default: no proxy.
# cert=("/path/to/client.pem", "/path/to/client.key"), # mTLS; or a single combined PEM path. Validated at construction — point at real files before uncommenting.
# Span Configuration
session_id="my-session",
service_name="my-service",
environment="production",
# Project Routing (required with workspace API keys — lum_<env>_ws_... —
# which serve several projects and carry no default; init raises without
# one of these; project_id wins over project_alias)
# project_id="00000000-0000-0000-0000-000000000000",
# project_alias="my-project-alias",
# User Identity
user_email="user@example.com",
user_name="Alice",
user_id="user-123",
# OpenTelemetry
auto_instrument_opentelemetry=True, # Auto-configure OTEL (default: True)
isolated=False, # Use private TracerProvider (default: False)
otel_context_propagation=None, # Join the ambient OTel context (default: on unless isolated)
auto_instrument_litellm=False, # Auto-configure LiteLLM (default: False)
# Data Masking
masking_function=None, # Custom masking function (optional)
span_masking=None, # SpanMaskingOptions: caps, key patterns, PII floor (optional)
# Noise Control
span_filter=None, # Drop spans before export: SpanFilter(...) or a predicate (optional)
# General
enabled=True, # Master off switch for tracing (default: True)
eager_export=True, # True: every span at start + end; 'root': only trace roots at start; False: end only
record_stacktrace=False, # Capture stack traces on exceptions (default: False)
# Additional options passed via **kwargs
# headers={"Custom-Header": "value"}, # Custom HTTP headers
)
Turning tracing off
enabled=False (or BEACON_ENABLED=false) is a real off switch, not a sampling hint. A disabled
client installs no TracerProvider, creates no exporters, instruments nothing, touches no process
global, and makes every span export a no-op — so no span data leaves the process, and a service
that never configured Beacon pays nothing for the import.
BeaconClient(endpoint="...", api_key="...", enabled=False) # or BEACON_ENABLED=false
A disabled client is also allowed to be unconfigured — the "endpoint or file_directory is
required" rule is waived rather than raised — which is what from_env(optional=True) builds on:
from lumenova_beacon import BeaconClient
# Traces where BEACON_ENDPOINT is set; silently does nothing where it isn't.
# No try/except, no env-gated opt-in around construction.
client = BeaconClient.from_env(optional=True)
Without optional=True, from_env() raises ConfigurationError when nothing is configured — as
does an explicit enabled=True, since that is asking for tracing you have not set up.
enabled governs span export only. The data APIs (datasets, prompts, experiments, evaluations,
traces) still need an endpoint and still raise without one — silently discarding a Dataset.create()
would be a worse failure than an exception. A disabled client that does have an endpoint keeps a
working REST transport, so those APIs continue to function normally.
File Transport
For local development or testing, use file_directory instead of endpoint:
from lumenova_beacon import BeaconClient
client = BeaconClient(
file_directory="./traces",
)
Integrations
Multimodal images. Image content blocks in traced input messages are normalized to Beacon's canonical shape ({"type": "image", "source": {...}}) regardless of the wire format your provider/framework used — OpenAI/LiteLLM image_url, Anthropic-native source, LangChain blocks in both the v0 (source_type) and v1 (base64/url) shapes, Bedrock Converse/Strands image blocks (including raw bytes, and images returned by tools), Gemini inline_data/file_data, and OTel GenAI incubating-semconv auto-instrumentation. This happens automatically — no extra configuration needed. Output messages and CrewAI are not yet covered.
1. LangChain/LangGraph
Automatically trace all LangChain and LangGraph operations — chains, agents, tools, retrievers, and LLM calls.
First, set your credentials. Every handler and config below relies on a BeaconClient that knows your endpoint and API key. Provide them in one of two ways — environment variables are preferred so credentials stay out of your code:
# Preferred: environment variables (BeaconClient() then needs no arguments)
$env:BEACON_ENDPOINT = "https://your-beacon-endpoint.lumenova.ai"
$env:BEACON_API_KEY = "your-api-key"
# Or pass them explicitly when constructing the client (overrides env vars)
client = BeaconClient(endpoint="https://your-beacon-endpoint.lumenova.ai", api_key="your-api-key")
Two entry points, same traces — pick based on whether your graph is checkpointed:
BeaconLangGraphHandler |
BeaconLangGraphConfig |
|
|---|---|---|
| What it is | A callback handler you attach via config={"callbacks": [...]} |
A config builder that owns a handler and returns a ready-made RunnableConfig |
| Best for | Chains, RAG, one-shot agent runs — no checkpointer | Checkpointed LangGraph agents that interrupt and resume |
| Interrupt/resume continuity | Manual (mark_interrupt() + your own ID tracking) |
Automatic — persists Beacon trace IDs into the checkpoint, continues the same trace on resume |
| Governance | Attach a separate BeaconLangGraphGovernanceHandler |
Pass governance=GovernanceConfig(...) in one step |
Use the handler for stateless chains and simple agent calls; use BeaconLangGraphConfig when your graph is compiled with a checkpointer and uses interrupt/resume, needs handoff detection, or needs governance.
Option A — BeaconLangGraphHandler (chains and one-shot agents)
from lumenova_beacon import BeaconClient, BeaconLangGraphHandler
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
client = BeaconClient() # endpoint + api_key from env (or pass them explicitly)
handler = BeaconLangGraphHandler(
session_id="session-123",
agent_name="planner", # Emitted as gen_ai.agent.name (optional)
agent_id="agent-uuid", # Emitted as gen_ai.agent.id (optional)
agent_version="2025-05-01", # Emitted as gen_ai.agent.version (optional)
)
# Use with request-time callbacks (recommended)
llm = ChatOpenAI(model="gpt-4")
response = llm.invoke(
"What is the capital of France?",
config={"callbacks": [handler]}
)
# Works with chains
prompt = ChatPromptTemplate.from_template("Tell me about {topic}")
chain = prompt | llm
response = chain.invoke(
{"topic": "AI"},
config={"callbacks": [handler]}
)
# Traces agents, tools, retrievers, and more
from langchain.agents import create_react_agent, AgentExecutor
agent = create_react_agent(llm, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools)
result = executor.invoke(
{"input": "What's the weather?"},
config={"callbacks": [handler]}
)
Per-invocation Identity Override
A single BeaconLangGraphHandler can serve many conversations or agents without mutating instance state — pass the reserved keys session_id, agent_id, agent_version via config={"metadata": {...}} and they flow into gen_ai.conversation.id / gen_ai.agent.id / gen_ai.agent.version for that one invocation:
response = chain.invoke(
{"topic": "AI"},
config={
"callbacks": [handler],
"metadata": {
"session_id": "conversation-456",
"agent_id": "planner",
"agent_version": "2025-05-01",
},
},
)
Option B — BeaconLangGraphConfig (checkpointed agents, interrupt/resume)
For LangGraph agents compiled with a checkpointer, use BeaconLangGraphConfig to automatically continue traces across interrupt/resume cycles. It owns a BeaconLangGraphHandler internally and persists the Beacon trace/root-span IDs into the LangGraph checkpoint, so a resumed run (even in a new process) continues the same trace instead of starting a new one. Pass the result of get_config() straight to invoke/ainvoke — don't add your own callbacks:
from lumenova_beacon import BeaconClient, BeaconLangGraphConfig
client = BeaconClient() # endpoint + api_key from env (or pass them explicitly)
beacon = BeaconLangGraphConfig(
graph=agent.graph, # must be compiled WITH a checkpointer
thread_id=thread_id,
agent_name="planner",
agent_id="agent-uuid", # Emitted as gen_ai.agent.id (optional)
agent_version="2025-05-01", # Emitted as gen_ai.agent.version (optional)
session_id="session-123",
autodetect_handoffs=True, # Auto-detect agent-to-agent handoffs
)
config = beacon.get_config()
result = await agent.graph.ainvoke(state, config)
# For async checkpointers (e.g., AsyncPostgresSaver):
config = await beacon.aget_config()
To resume, build the config again for the same thread_id — it re-reads the checkpoint, detects the interrupt, and continues the original trace (beacon.is_resume is then True). To add policy enforcement, pass governance=GovernanceConfig(...) — see Integrated Tracing + Governance with BeaconLangGraphConfig.
2. LiteLLM
Trace all LiteLLM operations across multiple LLM providers:
from lumenova_beacon import BeaconClient, BeaconLiteLLMLogger
import litellm
# Option 1: Auto-instrumentation
client = BeaconClient(auto_instrument_litellm=True)
# Option 2: Manual registration
client = BeaconClient()
litellm.callbacks = [BeaconLiteLLMLogger()]
# All LiteLLM calls are now traced
response = litellm.completion(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}],
metadata={
"generation_name": "greeting",
"session_id": "user-123",
}
)
3. Strands Agents
Trace AWS Strands Agent executions with automatic span hierarchy. Two integrations are available — the hook provider is recommended; the callback handler is legacy.
BeaconStrandsHooks (recommended) — implements the Strands HookProvider protocol,
registered via hooks=[...]. The agent span is always finalized (even when the model hits
max_tokens and Strands raises out of agent()), and per-request identity can be passed via
invocation_state so one long-lived handler serves many users. Requires strands-agents>=1.38.0.
from lumenova_beacon import BeaconClient, BeaconStrandsHooks
from strands import Agent
client = BeaconClient()
hooks = BeaconStrandsHooks(
session_id="my-session",
agent_name="My Agent",
agent_id="agent-uuid", # Emitted as gen_ai.agent.id (optional)
agent_version="2025-05-01", # Emitted as gen_ai.agent.version (optional)
)
agent = Agent(model=model, tools=[...], hooks=[hooks])
# Per-request identity (optional) — overrides constructor defaults for this call.
# project_alias / project_id route this call's spans to a Beacon project when
# one process serves several projects through a workspace API key:
result = agent(
"Hello, world!",
invocation_state={"beacon": {"user_email": "alice@corp.com", "project_alias": "sales"}},
)
print(hooks.trace_id) # Link to Beacon trace
Every integration accepts project_id / project_alias as constructor
defaults; the per-call channel is invocation_state["beacon"] here,
config={"metadata": {...}} for LangChain, _meta={"beacon": {...}} for MCP,
and litellm.completion(..., metadata={...}) for LiteLLM. project_id wins
over project_alias, and either one set per call fully replaces the constructor
pair rather than mixing with it.
BeaconStrandsHandler (legacy) — the original callback handler, registered via
callback_handler=. It additionally captures streaming time-to-first-token, which the hook
API cannot, but it does not finalize the agent span when Strands raises out of agent()
(e.g. max_tokens) — call handler.finalize() in a finally to close the span. Supports
strands-agents>=1.15.0.
from lumenova_beacon import BeaconClient, BeaconStrandsHandler
from strands import Agent
client = BeaconClient()
handler = BeaconStrandsHandler(session_id="my-session", agent_name="My Agent")
agent = Agent(model=model, callback_handler=handler)
try:
result = agent("Hello, world!")
finally:
handler.finalize() # closes the agent span even on the max_tokens raise path
print(handler.trace_id) # Link to Beacon trace
4. CrewAI
Trace CrewAI Crew executions via the event listener:
from lumenova_beacon import BeaconClient, BeaconCrewAIListener
from crewai import Agent, Crew, Task
client = BeaconClient()
# Auto-registers with CrewAI event bus
listener = BeaconCrewAIListener(
session_id="my-session",
crew_name="My Research Crew",
crew_id="crew-uuid", # Emitted as gen_ai.agent.id (optional)
agent_version="2025-05-01", # Emitted as gen_ai.agent.version (optional)
)
crew = Crew(agents=[...], tasks=[...])
result = crew.kickoff()
print(listener.trace_id) # Link to Beacon trace
5. MCP Server and Client
Trace an MCP (Model Context Protocol) server independently of any agent.
BeaconMCPMiddleware is a FastMCP server middleware that
intercepts the server's request dispatch and emits one server-side Beacon span per
request — so the server is observable regardless of which client or agent called it.
from fastmcp import FastMCP
from lumenova_beacon import BeaconClient, BeaconMCPMiddleware
BeaconClient() # configure once (BEACON_ENDPOINT/BEACON_API_KEY, or file_directory=...)
mcp = FastMCP("my-server")
mcp.add_middleware(BeaconMCPMiddleware(server_name="my-server"))
@mcp.tool
def add(a: int, b: int) -> int:
return a + b
mcp.run() # each tools/call now produces a SERVER-kind Beacon span
A single on_request dispatcher produces one span per request method (SpanKind.SERVER
for inbound, CLIENT for server-initiated). Every method is covered — mcp.method.name
is always set; unknown methods get a generic REQUEST span:
| MCP method | Span type | gen_ai.operation.name |
|---|---|---|
tools/call |
TOOL |
execute_tool (OTel spec) |
resources/read |
RETRIEVAL |
retrieval |
resources/subscribe/unsubscribe |
REQUEST |
subscribe_resource / … |
prompts/get |
REQUEST |
get_prompt |
tools/list, resources/list, … |
REQUEST |
list_* |
initialize, completion/complete |
REQUEST |
initialize / complete |
sampling/createMessage |
GENERATION |
create_message |
Trace topology — each request starts its own local root trace. If the caller
propagated a W3C traceparent in the request _meta, that remote context is attached as a
span link (link.type=remote_parent) rather than adopted as the parent: parenting under a
span this Beacon environment may never receive leaves the trace permanently "pending". Pass
continue_remote_trace=True when the caller demonstrably exports to the same environment.
Per-request identity — pass a reserved "beacon" key inside the request
_meta ({"beacon": {"user_email": "...", "session_id": "..."}}) to override the
constructor defaults for a single call; the bag also accepts user_name, user_id, and
a metadata dict (merged over the constructor metadata). Noise control — the low-signal methods are off
by default: discovery lists (capture_list_operations), ping (capture_ping), and
notifications (capture_notifications). Instrumentation never breaks the server: if span
setup fails, the request proceeds untraced.
BeaconMCPClient is the caller's half — a drop-in fastmcp.Client that fills the
traceparent and the beacon identity bag on every tools/call, resources/read and
prompts/get, so the server's spans join the caller's trace and carry its session with no
per-call-site code:
from lumenova_beacon import BeaconMCPClient, set_session
set_session(conversation_id)
async with BeaconMCPClient("http://localhost:8000/mcp") as client:
result = await client.call_tool("search", {"q": "beacon"})
Caller-supplied meta= always wins, and a caller beacon dict is merged key-by-key.
Client spans are on by default (capture_client_spans=True), typed (TOOL for tools/call)
and carrying the call's arguments and result. fastmcp's own client tracer is muted at the same
time, so its untyped span does not stack above Beacon's — skipped under isolated=True, where
those spans belong to the host's own pipeline. Turn the span off with
capture_client_spans=False where an agent framework already emits a TOOL span for the call,
and the mute off with mute_fastmcp_telemetry=False.
6. OpenTelemetry
Beacon automatically configures OpenTelemetry to export spans:
from lumenova_beacon import BeaconClient
from opentelemetry.instrumentation.anthropic import AnthropicInstrumentor
from opentelemetry.instrumentation.openai import OpenAIInstrumentor
# Initialize (auto-configures OpenTelemetry)
client = BeaconClient(
endpoint="https://your-beacon-endpoint.lumenova.ai",
api_key="your-api-key",
auto_instrument_opentelemetry=True # Default
)
# Instrument libraries
AnthropicInstrumentor().instrument()
OpenAIInstrumentor().instrument()
# Now all API calls are automatically traced!
from anthropic import Anthropic
anthropic = Anthropic()
response = anthropic.messages.create(
model="claude-3-5-sonnet-20241022",
messages=[{"role": "user", "content": "Hello!"}]
) # Automatically traced with proper span hierarchy
Supported Instrumentors
Install additional instrumentors as needed:
pip install opentelemetry-instrumentation-anthropic
pip install opentelemetry-instrumentation-openai
pip install opentelemetry-instrumentation-fastapi
pip install opentelemetry-instrumentation-redis
pip install opentelemetry-instrumentation-httpx
pip install opentelemetry-instrumentation-requests
One Trace Across Both APIs
Beacon's own span API and OTel instrumentors share a single trace tree: a native span
(@trace, client.trace(), client.create_span()) adopts the current OTel span as its
parent, and instrumentor spans created inside a native span nest under it.
@app.post("/turn") # FastAPI instrumentor -> SERVER span
@trace(span_type=SpanType.AGENT)
async def turn(): # native Beacon span, child of the SERVER span
anthropic.messages.create(...) # Anthropic instrumentor span, child of the agent span
Without this, native spans would start their own disconnected trace. It is on by default
and off when isolated=True — a split-routing setup exports instrumentor spans to a
different backend, so cross-parenting would leave each backend with partial traces.
Override with otel_context_propagation=True/False or BEACON_OTEL_CONTEXT_PROPAGATION.
Framework integrations (LangChain/LangGraph, Strands, CrewAI) adopt the ambient OTel span for their root spans too, so agent runs inside an instrumented service join the request trace.
Inbound Context (Cloud Run, GCLB, ELB)
A tracing-aware load balancer injects its own W3C traceparent. The instrumentor adopts it,
so your SERVER span has a remote parent whose span lives in the provider's tracing system and
never reaches Beacon. Since trace-level fields are materialized from the parentless root,
that trace has no root at all — it stays Pending… forever with no name, session or agent.
Dropping all inbound context fixes that and breaks genuine distributed traces. The two are indistinguishable from the headers alone, so it is a policy:
BeaconClient(inbound_context="trusted") # or BEACON_INBOUND_CONTEXT=trusted
| mode | behavior |
|---|---|
honor (default) |
Extract normally — today's behavior, nothing changes |
ignore |
Never extract; every request starts a local root |
trusted |
Extract only when a Beacon peer's beacon-traceparent marker travels alongside |
Every Beacon service writes that marker on outbound calls — HTTP, queue carriers and MCP
_meta — in all three modes, so the marker is already in the field by the time you switch
a receiver to trusted. It carries the traceparent value rather than a flag, so when an
intermediary rewrites traceparent with its own span the real Beacon parent is still
recovered — a case honor gets wrong.
| Inbound request | honor |
ignore |
trusted |
|---|---|---|---|
No traceparent |
root | root | root |
| Infrastructure only (Cloud Run/ELB) | ghost parent → Pending… | root | root |
| Beacon peer (marker present) | joins | dropped | joins |
| Non-Beacon OTel peer (no marker) | joins | dropped | dropped |
Beacon peer, traceparent rewritten |
joins the ghost | root | recovers peer trace |
Row four is the cost of trusted — stay on honor if you join traces from services that are
not Beacon-instrumented and have no load balancer in front.
Only the parent span is subject to the policy. trusted still extracts W3C baggage
normally, whether or not it accepts the parent — a request's tenant or user is a different
question from which span it descends from. ignore is the blunt mode and drops both.
The policy wraps the process-global OpenTelemetry propagator, so it covers every instrumentor
(FastAPI, ASGI, gRPC, Flask) with no per-service code — and isolated=True does not scope
it: spans bound for another backend are re-parented too, and a warning says so.
The marker is not authenticated. It distinguishes a Beacon-instrumented peer from an
infrastructure component that injects traceparent — not a trusted party from an attacker.
Session and Agent Context
Beacon derives a trace's session and agent from its root span. In an instrumented
service that root is the instrumentor's SERVER span — created before your handler knows
which conversation it is serving. set_session() / set_agent() bridge that gap:
from lumenova_beacon import set_agent, set_session
@app.post("/turn")
async def turn(request: TurnRequest):
conversation = await get_or_create_conversation(request)
set_session(conversation.id) # back-fills the open SERVER span
set_agent("Pressella", framework="my-engine")
...
- Values are stored in ContextVars, so they are task-local and inherited by child tasks —
concurrent requests do not see each other's session. This covers
async defhandlers andasyncio.to_thread/anyio.to_thread.run_sync(both copy the context), so sync FastAPI endpoints are fine too. A rawthreading.ThreadorThreadPoolExecutor.submitstarts with an empty context: callset_session()inside the worker, not before it. - Every span started afterwards is stamped, including instrumentor spans — whenever
Beacon owns the global
TracerProvideror attached to an existing one. Withisolated=Trueand no user identity configured, Beacon deliberately leaves the global provider untouched, so instrumentor spans are not stamped (a startup log says so). - The root span and the span currently in flight are back-filled at the moment you
call — not every open ancestor in between. That back-fill is what makes the trace group
into the session and show the agent name. Roots are tracked for instrumentor spans and
for
client.trace()/@traceroots; a bareclient.create_span()root is not tracked, so there only spans started after the call pick the value up. get_session()/get_agent()read the current values;clear_session()/clear_agent()reset them (useful in long-lived workers that process unrelated jobs). Clearing affects spans started afterwards only — an attribute already written to an open or exported span is not removed, so prefer starting a new trace per job over relying onclear_session()to detach the previous one.- Setting an agent name also renames the trace in the UI: the backend derives a trace's
display name as
agent_name or root_span_name.
Distributed traces. The backend treats a span as the trace root only when it has no
parent at all. If an upstream gateway, proxy, or service propagates a W3C traceparent
into your service, the local SERVER span has a remote parent and is therefore not the
trace root: back-filling still puts the attributes on the span, but the trace-level
session/agent columns are materialized from the parentless root in the upstream process.
Set the ambient context there, or stop propagating traceparent into this service. Beacon
logs a warning once per process when it detects this.
Native spans resolve session in this order: explicit session_id= → ambient
set_session() → parent span → BeaconClient(session_id=...).
The attribute keys written are session.id (session_id on native spans) and
gen_ai.agent.name / .version / .framework / .id — the keys the Beacon backend
reads when materializing trace-level fields. Pass session ids as strings: a UUID or
int is coerced with a warning, since OTel drops non-string attribute values outright.
Crossing Process Boundaries (Queues and Topics)
Trace context lives in ContextVars and the OpenTelemetry context — both in-process. When
work leaves through a queue, the consumer starts an unrelated trace. inject_trace_context()
and start_consumer_span() carry it across in whatever flat string bag the transport
offers (Pub/Sub attributes, Kafka headers, SQS message attributes):
from lumenova_beacon import inject_trace_context, start_consumer_span
# producer
publisher.publish(topic, payload, **inject_trace_context())
# consumer
def on_message(message):
with start_consumer_span("write-article", carrier=message.attributes) as span:
span.set_input(message.data)
...
Keys written: traceparent, plus beacon-session-id when an ambient session is set. An
empty carrier is not a "nothing to propagate" signal — a session is still propagated when
no span is in flight.
Extraction is case-insensitive, decodes bytes values, accepts an iterable of
(key, value) pairs as well as a mapping (Kafka's headers()), and unwraps boto3's nested
SQS shape ({"traceparent": {"StringValue": ...}}), so MessageAttributes can be passed
straight through.
Topology — mode= decides how the consumer relates to the producer:
| mode | result |
|---|---|
link (default) |
own root trace + a span link back (link.type=remote_parent) |
continue |
adopts the producer's span as parent — one end-to-end distributed trace |
correlate |
own root, no link; upstream trace id stored as beacon.remote_trace_id |
link is the default for the same reason continue_remote_trace defaults off: Beacon
materializes trace-level fields from the parentless root, so continuing a trace whose root
never reaches this environment leaves it "pending" forever. It is also the only mode that
works for batch consumers, which have several upstreams. Use continue when both ends
export to the same Beacon.
The span type defaults to TASK — handling a message is a unit of work, and these spans
are usually trace roots, where the generic SPAN type leaves the whole trace untyped.
The propagated session is applied for the duration of the block (adopt_session=True),
scoped so a worker looping over unrelated jobs cannot carry one message's session into the
next. Pass extra attributes with attributes= — messaging semconv keys live in
lumenova_beacon.tracing.semconv (MESSAGING_SYSTEM, MESSAGING_DESTINATION_NAME, …).
Calling set_session() inside a continue-mode block stamps the span but not the
trace: the adopted parent makes it a non-root, and trace-level session/agent are
materialized from the parentless root — here the producer's span, in the producing process.
Set the session there instead, or use link. The SDK warns once when this happens rather
than accepting the call silently.
extract_trace_context(carrier) returns the raw RemoteContext if you need to build the
span yourself.
Tracing
Decorator Tracing
The @trace decorator automatically captures function execution:
from lumenova_beacon import trace
# Simple usage
@trace
def process_data(data):
return data.upper()
# With custom name
@trace(name="custom_operation")
def another_function():
pass
# Capture inputs and outputs
@trace(capture_args=True, capture_result=True)
def calculate(x, y):
return x + y
# Works with async functions
@trace
async def async_operation():
await some_async_call()
Manual Tracing
For more control, use context managers:
from lumenova_beacon import BeaconClient
from lumenova_beacon.types import SpanKind, StatusCode
client = BeaconClient()
# Context manager
with client.trace("operation_name") as span:
span.set_attribute("user_id", "123")
span.set_input({"query": "search term"})
try:
result = do_work()
span.set_output(result)
span.set_status(StatusCode.OK)
except Exception as e:
span.record_exception(e)
span.set_status(StatusCode.ERROR, str(e))
raise
# Async context manager
async with client.trace("async_operation") as span:
result = await async_work()
span.set_output(result)
# Direct span creation
span = client.create_span(
name="manual_span",
kind=SpanKind.CLIENT,
)
span.start()
# ... do work ...
span.end()
Sessions
A session_id groups related traces — a multi-turn conversation, a batch run, a single user journey — so they can be filtered together in Beacon. Set it once on the client (or via BEACON_SESSION_ID) and it's inherited by every span created through the client. You can also override it per integration handler (BeaconLangGraphHandler(session_id=...), BeaconStrandsHandler(session_id=...), etc.), per LangChain invocation via config={"metadata": {"session_id": ...}}, or per span via span.set_attribute("session_id", "...").
Agentic Governance
Enforce governance policies on AI agent actions in real time. The governance system evaluates tool calls and LLM invocations against policy stacks before and after execution, blocking actions that violate your rules.
Key capabilities:
- Pre & post execution hooks — evaluate actions before they run (prevent violations) and after (audit outputs)
- Fail-open resilience — defaults to allowing actions when the governance API is unreachable
- Violation strategies — raise immediately, stop gracefully after current action, or escalate after N violations
- Content reinjection — policies that emit a top-level
outputstring (e.g. PII-masked tool output) can have that replacement applied automatically via@governance,handler.wrap(...)on aBeaconLangGraphGovernanceHandlerinstance (see Wrapping for content reinjection below), or the LangGraph helperswrap_tool_node/wrap_chat_model. Callback-only attachment observes but does not substitute.
Decorator
from lumenova_beacon import governance
# Bare decorator — requires GovernanceConfig on the active BeaconClient
@governance
def search_web(query: str) -> str:
return web_search(query)
# With policy stack IDs
@governance(stack_ids=["my-policy-stack"])
def send_email(to: str, body: str) -> bool:
return email_client.send(to, body)
# Tag-based policy discovery with strict mode
@governance(tags=["env:prod", "team:security"], fail_open=False)
def delete_record(record_id: str) -> None:
db.delete(record_id)
# Works with async functions
@governance(stack_ids=["my-policy-stack"])
async def run_query(sql: str) -> list[dict]:
return await db.execute(sql)
# Generators and async generators are governed as a stream
@governance(stack_ids=["my-policy-stack"], type_="llm")
def stream_answer(prompt: str):
for chunk in llm.stream(prompt):
yield chunk
Streaming and generators
A decorated generator (or async generator) keeps its kind (inspect.isgeneratorfunction / inspect.isasyncgenfunction stay true) and is governed as a stream: the pre hook runs when iteration starts (with the call's keyword arguments), the post hook sees the joined chunk text as output (string chunks and, for type_='llm', message chunks are concatenated in order; a chunk without text among them is rendered with str() in place so the surrounding text stays contiguous, and a stream with no text at all is sent as the repr of the collected list), the generator's return value is preserved, and an async def ... yield evaluates on the async client so nothing blocks the event loop. On a LangChain @tool, whose function LangChain calls without iterating, the pre hook runs at call time and the tool returns the governed stream as a plain function would. send() / throw() reach a sync generator in passthrough mode; in buffer mode the body has already run when the first chunk is released, so a sent value is discarded and a thrown exception surfaces at the consumer. Async generators forward aclose() only.
There is no per-chunk evaluation — the governance API judges whole outputs — so stream_mode decides when the consumer sees the chunks:
stream_mode='buffer'(default) collects every chunk, evaluates once, and releases the chunks on allow (or on fail-open, logged as unevaluated). A block means nothing reached the consumer; a policyoutputreplacement is yielded as a single chunk in place of the stream. The trade-off is latency: the consumer waits for the whole response plus one evaluation, and an open-ended stream never releases — usepassthroughfor those.stream_mode='passthrough'yields chunks as they are produced and evaluates after the stream is exhausted. A block raisesGovernanceViolationErrorafter the consumer already received the content — audit / stop-the-agent semantics, not content prevention — and a replacement cannot be applied (it is discarded with a warning). Whenever the stream ends early — the consumer stopped (break,close(), a dropped connection), the task was cancelled, or the stream itself raised — the chunks that were delivered are evaluated right there (a WARNING is logged for every early end). A block is raised fromclose()or in place of the error that ended the stream; underasyncio.CancelledErrorit is logged at ERROR and the cancellation propagates, andKeyboardInterrupt/SystemExitskip the evaluation call. Python reports a raise from a garbage-collected generator'sclose()as an ignored exception, so the WARNING logged first is the durable record. Only a stream that delivered nothing is left unjudged. AGovernanceViolationErrorraised after delivery hasdelivered=True.
With the post hook disabled (enabled_hooks={'pre_tool'} / {'pre_llm'}) there is no verdict to wait for and the stream is always passed through live.
LangChain/LangGraph Callback Handler
from lumenova_beacon import BeaconLangGraphHandler, BeaconLangGraphGovernanceHandler
from langchain_openai import ChatOpenAI
handler = BeaconLangGraphHandler(session_id="session-123")
governance_handler = BeaconLangGraphGovernanceHandler(
stack_ids=["my-policy-stack"],
fail_open=True,
on_violation="raise",
)
llm = ChatOpenAI(model="gpt-4")
response = llm.invoke(
"What is the capital of France?",
config={"callbacks": [handler, governance_handler]}
)
The same callbacks fire for stream / astream: pre_llm runs before the first chunk, and post_llm sees the full response text as input.llm.output plus input.llm.token_usage. The callback handler reads usage from the message's usage_metadata, then llm_output, then the usage summed across streamed chunks; a wrapped model reads the message's usage_metadata on invoke and sums the chunks on stream. Every path sums chunk usage the way LangChain does, so invoke and stream report the same numbers, and a missing total_tokens is derived from the parts — a token-budget rule that never tripped on streamed calls will start tripping. Because LangChain reports the end of a run only after the last chunk was yielded, a post-phase block through a callback raises after the caller received the content. To hold a stream back until the verdict is in, wrap the model instead (next section).
Wrapping for content reinjection
Use handler.wrap(...) (instead of callback-only attachment) when a policy returns a top-level output field that should actually replace the tool arguments, tool return value, LLM prompt, or LLM response. Callbacks can only observe and block; wrap rebinds the inner callable so the substitution takes effect.
wrap is an instance method — construct a handler first, then call .wrap(...) on the instance.
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from lumenova_beacon import BeaconLangGraphGovernanceHandler
@tool
def read_file(path: str) -> str:
"""Read a file."""
with open(path) as f:
return f.read()
# 1. Construct the handler — it owns shared violation state.
handler = BeaconLangGraphGovernanceHandler(
stack_ids=["pii-redaction-stack"],
fail_open=True,
on_violation="raise",
)
# 2. Wrap on the INSTANCE. Mutates in place; returns the same target.
wrapped_tool = handler.wrap(read_file) # BaseTool
wrapped_tools = handler.wrap([read_file]) # list[BaseTool]
wrapped_llm = handler.wrap(ChatOpenAI(model="gpt-4o-mini")) # BaseChatModel
# 3. All wrapped targets share handler.violation_count, the stopped flag,
# and the invocation chain via closure.
print(handler.violation_count)
Notes:
wrapaccepts aBaseTool, aSequence[BaseTool], or aBaseChatModel.- The input is mutated in place:
BaseTool.func/coroutineandBaseChatModel.invoke/ainvoke/stream/astreamare rebound. Keep a reference before wrapping if you need an unwrapped copy. - Idempotent: wrapping an already-wrapped target is a no-op.
stream/astreamon a wrapped chat model are governed. With the defaultstream_mode='buffer'the chunks are collected, the joined response is evaluated once, and the chunks are released only on allow (a policyoutputreplaces them with a single chunk).stream_mode='passthrough'yields live and evaluates after the stream ends, so a block raises after delivery; whatever was delivered before an early end (stop, cancellation, an error in the stream) is evaluated right there. There is no per-chunk evaluation. When LangChain routesstream()throughinvoke()(no native streaming, or astreamthat callsinvokeitself), the call is evaluated once, not twice. A model whoseastreamis a coroutine rather than an async generator is refused withGovernanceConfigurationError(no shipped model has that shape) so a wrapped model is never half-governed.input.llm.token_usageis read from the message'susage_metadata, falling back to the provider'stoken_usage/usageinresponse_metadata.- A wrapped model sends
input.llm.token_usageoninvoke/ainvoke(from the message'susage_metadata) and onstream/astream(summed across chunks). - The governed methods live on the instance, so everything LangChain derives from it delegates back to them:
batch/abatchfan out overinvoke/ainvoke, andbind_toolsandbindreturn aRunnableBindingwhoseboundis the wrapped model (with_structured_outputaRunnableSequencewhose first step is such a binding). A bound model used by a LangGraph ReAct agent is governed;wrap()does not accept (and does not need) the derived Runnable.configurable_fields/configurable_alternativesare the exception: they rebuild the model from its declared fields when a configurable value is supplied, or swap in an alternative, so the wrapped model rebinds those two as well and governs whatever the derived Runnable resolves to (the rebuilt copy is wrapped; a chat-model alternative is wrapped in place; an alternative that is not a chat model is named in a WARNING when derived and logged at ERROR when selected, because it cannot be governed). - A tool whose
func/coroutineis a generator function is governed like a decorated generator: pre hook at call time, post hook on the stream the tool returns. - Tools that override
_run/_arundirectly (nofunc/coroutineattribute) cannot be wrapped — apply@governanceto the inner function before building the tool, or exposefunc/coroutine.
A full LangGraph StateGraph example using this pattern lives in the SDK source tree at examples/llm_integrations/langchain/langchain_governance_wrap.py.
BeaconLangGraphAgent — Wrapper with Violation Dispatch
For LangGraph agents, BeaconLangGraphAgent is the highest-level governance entry point: it owns a BeaconLangGraphConfig internally, exposes invoke / stream / stream_events (plus async variants), and lets you pick how GovernanceViolationError is surfaced via violation_mode:
'raise'(default) — re-raise to the caller, same as the bare config.'state'— append phase-appropriate messages via the checkpointer and return / yield cleanly. LLM-phase blocks become anAIMessage; tool-phase blocks become aToolMessagewith sibling reconciliation so OpenAI's "tool_calls must be answered" invariant holds.'emit'— yield a synthesisedon_custom_eventfrom the streaming entry points; falls back to'state'forinvoke/ainvokewhich have no event stream.
from lumenova_beacon import BeaconClient, BeaconLangGraphAgent, GovernanceConfig
client = BeaconClient()
agent = BeaconLangGraphAgent(
graph=compiled_graph,
thread_id="thread-123",
governance=GovernanceConfig(stack_ids=["my-policy-stack"], fail_open=True),
violation_mode="state",
agent_name="planner",
agent_id="agent-uuid", # Emitted as gen_ai.agent.id (optional)
agent_version="2025-05-01", # Emitted as gen_ai.agent.version (optional)
session_id="session-123",
)
# Same surface as graph.invoke / graph.stream — no config= plumbing needed.
result = agent.invoke({"messages": [("user", "delete production")]})
# Streaming with state-mode reconciliation:
for chunk in agent.stream({"messages": [...]}):
...
# Inspect the last violation (e.g. when emit-mode falls back to a WARN-only path):
if agent.last_violation:
print(agent.last_violation.phase, agent.last_violation.policies)
The wrapper requires a graph compiled with a checkpointer (same as BeaconLangGraphConfig) and a GovernanceConfig — without one it has nothing to enforce and raises at construction. Reconciliation writes are persisted with callbacks / tags stripped from the config so the checkpointer touch is invisible to the tracer (no stray second trace).
Integrated Tracing + Governance with BeaconLangGraphConfig
For LangGraph agents, pass a GovernanceConfig to BeaconLangGraphConfig to get both tracing and governance in a single setup:
from lumenova_beacon import BeaconLangGraphConfig, GovernanceConfig
beacon = BeaconLangGraphConfig(
graph=agent.graph,
thread_id=thread_id,
agent_name="planner",
governance=GovernanceConfig(
stack_ids=["my-policy-stack"],
fail_open=True,
on_violation="raise",
max_violations=3,
),
)
config = beacon.get_config()
result = await agent.graph.ainvoke(state, config)
GovernanceConfig Options
from lumenova_beacon import GovernanceConfig
config = GovernanceConfig(
stack_ids=["stack-1", "stack-2"], # Policy stack IDs (at least one of stack_ids/tags required)
tags=["env:prod"], # Tag-based policy discovery
fail_open=True, # Allow actions when evaluation could not run: API unreachable,
# errored, or the payload refused locally (default: True)
enabled_hooks={"pre_tool", "post_tool"}, # Which hooks to run (default: all four)
on_violation="raise", # "raise" (immediate) or "stop" (graceful shutdown)
max_violations=5, # Escalate to stop mode after N violations
timeout=2.0, # API call timeout in seconds
debug=False, # Enable debug logging
include_state=True, # Forward LangGraph state as input.context.state (default: True)
state_filter=None, # Optional (state_dict) -> dict to redact/whitelist state fields
state_max_bytes=32 * 1024, # Cap on serialized state; over-cap state is replaced with a marker
max_payload_bytes=256 * 1024, # Ceiling on the whole evaluation request body (see below)
stream_mode="buffer", # "buffer" (hold a wrapped model's stream until allowed) or "passthrough"
)
Available hooks: pre_tool, post_tool, pre_llm, post_llm.
The Payload Ceiling
Evaluation payloads carry the full content of the action under review — the LLM's output, the user's message, tool results — with no per-field truncation of action content (LangGraph state context is separately capped: see state_max_bytes, whose over-cap replacement and per-message clipping are marked in-band). max_payload_bytes (default 256 KiB) bounds the whole serialized request body so it cannot outgrow the deployment's proxy body limit or the request timeout.
An over-ceiling payload is refused, never truncated to fit: a policy handed a fragment would return a verdict for content it never saw, and the backend would persist that verdict as a completed evaluation. Instead the SDK raises GovernancePayloadTooLargeError without sending anything, and fail_open decides what happens: with fail_open=False the action is blocked; with the default fail_open=True it proceeds, logged at ERROR as an unevaluated action.
If you raise max_payload_bytes, raise your reverse proxy's body limit with it (nginx's client_max_body_size defaults to 1 MB) — the SDK cannot discover it. Expect evaluation requests and backend audit rows to grow with the ceiling.
Handling Violations and Failures
from lumenova_beacon.exceptions import (
GovernancePayloadTooLargeError,
GovernanceTimeoutError,
GovernanceViolationError,
)
try:
result = agent.invoke(state, config)
except GovernanceViolationError as e:
print(e) # Human-readable reason
print(e.policies) # List of rules that were evaluated
print(e.latency_ms) # Evaluation latency
except GovernancePayloadTooLargeError as e:
print(e.payload_bytes) # Serialized size of the refused body
print(e.max_bytes) # The configured ceiling
print(e.rejected_by) # 'sdk' (local ceiling) or 'server' (proxy/API 413)
except GovernanceTimeoutError as e:
print(e.payload_bytes) # Size of the body whose evaluation timed out
All governance failures subclass GovernanceError, so a single except GovernanceError still catches everything; the subclasses only matter when you want to alert on them differently. Two more you may see in logs: GovernanceRequestAbortedError (the connection dropped mid-request — commonly a proxy enforcing its body limit by closing the connection instead of answering 413) and GovernancePayloadEncodingError (the payload could not be JSON-encoded whole — the SDK first coerces non-JSON values, keeping Pydantic-style objects structural via model_dump() and turning others like datetime or UUID into strings, and refuses rather than sending a payload it could only encode by dropping content, such as a circular reference — deterministic, so the action would run unevaluated on every call until fixed). With fail_open=True (the default) none of these failure errors raise — policy violations still do, so the GovernanceViolationError branch above applies regardless; the action proceeds and the SDK logs the unevaluated action — at ERROR for all of the above, since each means enforcement did not run for a persistent or locally fixable reason.
System Probes
Run autonomous AI agents that probe your HTTP system, classify each outcome, and produce a scored markdown report. The agent (LangGraph + LLM + scoring) runs on Beacon; the SDK only claims the run, polls for HTTP requests the agent wants executed, runs each one against your target with your local credentials, and PATCHes the response back. In SDK mode, Beacon never sees your target's URL or credentials.
Use a probe when you want broad coverage of an HTTP API without writing tests by hand, when you want adversarial probing (e.g. OWASP LLM Top 10) against an LLM-backed endpoint, or when your API lives behind a VPN / private VPC where Beacon can't reach it directly.
Configure the run from the Beacon UI (target system → probe evaluator → click Run probe), then run it locally:
Path A — built-in HTTP dispatcher
Pass auth as a kwarg matching the target's auth_scheme_hint. The SDK fires httpx itself and injects the auth header on every outbound request.
from lumenova_beacon import Probe
# Bearer auth (auth_scheme_hint='bearer')
Probe.run("<run-id-from-the-ui>", bearer_token="<your-bearer-token>")
# Custom header auth (e.g. auth_scheme_hint='custom_headers')
Probe.run("<run-id-from-the-ui>", headers={"X-API-Key": "<your-api-key>"})
# No auth (auth_scheme_hint='none')
Probe.run("<run-id-from-the-ui>")
Probe.run blocks until the run reaches a terminal status. Use Probe.arun(...) for the async variant.
Path B — custom callable
Own the connection yourself — for custom auth flows, non-HTTP transports, or mocks.
import time
from lumenova_beacon import Probe, ProbeHttpRequest, ProbeHttpSuccessResponse
def my_dispatcher(req: ProbeHttpRequest) -> ProbeHttpSuccessResponse:
started = time.monotonic()
response = my_internal_client.send(
req.method,
req.url,
headers=req.request_headers,
body=req.request_body,
)
return ProbeHttpSuccessResponse(
duration_ms=int((time.monotonic() - started) * 1000),
status_code=response.status_code,
response_headers=dict(response.headers),
response_body=response.text,
)
Probe.run("<run-id-from-the-ui>", target_callable=my_dispatcher)
Mixing target_callable with an auth kwarg raises immediately at Probe.run entry — pick one.
The SDK uses the same BEACON_ENDPOINT + BEACON_API_KEY env vars (or BeaconClient configuration) as the rest of the SDK to reach Beacon.
Dataset Management
Manage test datasets with an ActiveRecord-style API. All methods have async variants with an a prefix (e.g., acreate, aget, alist).
from lumenova_beacon import BeaconClient
from lumenova_beacon.datasets import Dataset, DatasetRecord
client = BeaconClient()
# Create dataset
dataset = Dataset.create(
name="qa-evaluation",
description="Question answering test cases"
)
# Add records with flexible column-based data
dataset.create_record(
data={
"prompt": "What is AI?",
"expected_answer": "Artificial Intelligence is...",
"difficulty": "easy",
"category": "definitions"
}
)
# Bulk create records
dataset.bulk_create_records([
{"data": {"question": "What is ML?", "expected_answer": "Machine Learning..."}},
{"data": {"question": "What is DL?", "expected_answer": "Deep Learning..."}},
])
# List, get, update, delete
datasets, pagination = Dataset.list(page=1, page_size=20, search="qa")
dataset = Dataset.get(dataset_id="dataset-uuid", include_records=True)
dataset.update(name="updated-name", description="New description")
dataset.delete()
Prompt Management
Version-controlled prompt templates with labels. All methods have async variants with an a prefix.
from lumenova_beacon import BeaconClient
from lumenova_beacon.prompts import Prompt
client = BeaconClient()
# Create text prompt
prompt = Prompt.create(
name="greeting",
template="Hello {{name}}! Welcome to {{company}}.",
description="Customer greeting template",
tags=["customer-support", "greeting"]
)
# Create chat prompt
prompt = Prompt.create(
name="support-bot",
messages=[
{"role": "system", "content": "You are a helpful assistant for {{product}}."},
{"role": "user", "content": "{{question}}"}
],
)
# Get latest version (sync)
prompt = Prompt.get(name="greeting")
# Get specific version (async)
prompt = await Prompt.aget(name="greeting", version=2)
# Get labeled version (sync)
prompt = Prompt.get(name="greeting", label="production")
# Get by ID (async)
prompt = await Prompt.aget(prompt_id="prompt-uuid")
# Format prompt with variables
message = prompt.format(name="Alice", company="Acme Corp")
# Result: "Hello Alice! Welcome to Acme Corp."
# Versioning and labels
new_version = prompt.publish(
template="Hi {{name}}! Welcome to {{company}}. We're excited to have you!",
message="Added enthusiastic tone"
)
prompt.set_label("production", version=2)
# Introspect labels: list them, or look one up by name. Each PromptLabel
# references its version by id (prompt_version_id), not by number.
labels = prompt.list_labels()
for lbl in labels:
print(lbl.label, "->", lbl.prompt_version_id)
prod = prompt.get_label("production") # raises PromptNotFoundError if absent
# Convert to LangChain template
lc_prompt = prompt.to_langchain() # Returns PromptTemplate or ChatPromptTemplate
LangChain Conversion
from langchain_core.prompts import PromptTemplate, ChatPromptTemplate
# Convert text prompt to LangChain (sync)
prompt = Prompt.get(name="greeting", label="production")
lc_prompt = prompt.to_langchain() # Returns PromptTemplate
result = lc_prompt.format(name="Bob", company="TechCorp")
# Convert chat prompt to LangChain (async)
chat_prompt = await Prompt.aget(name="support-bot", label="production")
lc_chat = chat_prompt.to_langchain() # Returns ChatPromptTemplate
messages = lc_chat.format_messages(product="DataHub", question="Reset password?")
# Use in chain
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4")
chain = lc_chat | llm
response = await chain.ainvoke({"product": "CloudSync", "question": "Why sync failing?"})
List and Search
# List all prompts (sync) — returns (prompts, pagination metadata)
prompts, pagination = Prompt.list(page=1, page_size=20)
print(pagination.total, pagination.total_pages)
# Filter by tags (async). Passing several tags matches prompts carrying ANY of
# them, not all — use Prompt.list_tags() to discover the tags in use.
support_prompts, _ = await Prompt.alist(tags=["customer-support"])
support_or_billing, _ = await Prompt.alist(tags=["customer-support", "billing"])
# Combine AND/OR with any_of()/all_of() (nestable up to 4 levels, 32 tags):
# prompts tagged customer-support AND (production OR staging)
from lumenova_beacon import all_of, any_of
filtered, _ = Prompt.list(tags=all_of("customer-support", any_of("production", "staging")))
# Search by text (sync)
results, _ = Prompt.list(search="greeting")
# Filter by type (sync)
chat_prompts, _ = Prompt.list(prompt_type=PromptType.CHAT)
# Filter by category (sync). Pass Prompt.UNCATEGORIZED to list only prompts
# that have no category.
support, _ = Prompt.list(category="customer-support")
uncategorized, _ = Prompt.list(category=Prompt.UNCATEGORIZED)
# Sort (sync). sort_by: 'created_at' | 'updated_at' | 'name';
# sort_order: 'asc' | 'desc'. Omitted params fall back to the server
# defaults ('created_at', 'desc').
newest, _ = Prompt.list(sort_by="updated_at", sort_order="desc")
# Iterate over all pages (sync)
prompts, page = Prompt.list()
while page.page < page.total_pages:
more, page = Prompt.list(page=page.page + 1)
prompts.extend(more)
Categories
# List the distinct category names in use across the project (sync)
categories = Prompt.categories() # -> ['customer-support', 'definitions', ...]
# Async
categories = await Prompt.acategories()
categories()lists only categories that are actually assigned to a prompt; prompts with no category are not represented. UsePrompt.UNCATEGORIZEDwithlist()orbulk_get()to fetch those.
Tags
# List the distinct tags in use across the project, sorted (sync)
tags = Prompt.list_tags() # -> ['billing', 'customer-support', 'production', ...]
# Async
tags = await Prompt.alist_tags()
# Feed them straight back into a filter
prompts, _ = Prompt.list(tags=tags[:2])
The discovery method is
list_tags(), nottags()—prompt.tagsis already the tag list of an individual prompt. Filtering withlist(tags=[...])matches prompts carrying any of the given tags; for AND/OR combinations pass anany_of()/all_of()expression instead of a plain list (see List and Search).
Bulk Fetch
Fetch many prompts in one call, by IDs or by category. Each returned prompt
carries its latest version, so it's immediately usable with .format().
Pass exactly one of prompt_ids or category.
# Fetch several prompts by ID (sync). Unknown or inaccessible IDs are skipped
# and a warning naming the misses is emitted — valid prompts are still returned.
prompts = Prompt.bulk_get(prompt_ids=["id-1", "id-2", "id-3"])
# Fetch every prompt in a category (async)
support_prompts = await Prompt.abulk_get(category="customer-support")
# Fetch prompts that have no category, using the UNCATEGORIZED sentinel
uncategorized = Prompt.bulk_get(category=Prompt.UNCATEGORIZED)
for prompt in prompts:
print(prompt.name, prompt.format(name="Alice"))
Passing both
prompt_idsandcategory(or neither) raisesPromptValidationError.
Duplicate Names
Prompt names are not unique within a project. When you fetch by name and more
than one prompt matches, Prompt.get(name=...) returns the first match
(project-owned, then oldest) and emits a warning instead of failing:
import warnings
with warnings.catch_warnings():
warnings.simplefilter("always")
prompt = Prompt.get(name="greeting") # warns: 'Multiple prompts named ...'
Fetch by prompt_id to target a specific prompt unambiguously.
Experiment Management
Experiments run a pipeline of steps (PROMPT, LLM, EVALUATION, PYTHON, EXTERNAL_AGENT, GUARDRAIL, EXTERNAL_SYSTEM) over a dataset. Provide either a flat steps= list (single-stage) or a macro graph of stages= (multi-stage). The two are mutually exclusive. The server orchestrates each run and stores its results.
An Experiment is a definition; each run() creates a numbered ExperimentRun that owns its status, progress, outputs, and its own copy of the graph as executed. Runs never overwrite each other, the definition can be edited between runs (each edit bumps definition_version), and runs of the same experiment can be compared.
run = experiment.run(label="gpt-5 baseline") # -> ExperimentRun #N, queued
run.wait_for_completion(timeout=600) # poll until terminal
records, _ = run.results(status="failed") # per-record, per-step detail
stats = run.stats() # step-result counts + evaluation scores
for r in experiment.runs(): # newest first
print(r.run_number, r.label, r.status.name, r.total_cost)
experiment.latest_run # ExperimentRun | None
run.cancel(); run.continue_(); run.retry_failed(); run.delete()
run.rerun_records(all_failed=True) # re-execute records of *this* run
Experiment.status, start(), reset(), cancel(), continue_experiment(), get_progress(), list_records(), get_run_stats() and list_runs() remain as deprecated latest-run forwarders and emit DeprecationWarning. The per-record (step × record) result type is ExperimentStepResult (formerly misnamed ExperimentRun), listed with list_step_results().
Experiment variables and sweeps
An ExperimentVariable is defined once on the experiment and bound from step parameters (config["parameter_bindings"]), mapping slots (VariableMapping.variable(id)) or typed stage ports (StageInputBinding(variable_id=...)) through its stable id. A variable marked required_at_runtime takes its value from variable_values on each run(); run_sweep() accepts lists and starts one run per combination (at most 25 per call), returning an ExperimentRunBatch. Each run keeps the values it executed with in run.variable_values (resource display names in run.variable_resource_names).
from lumenova_beacon.experiments import (
Experiment, ExperimentStep, ExperimentStepType, ExperimentVariable,
)
model = ExperimentVariable(id="var_model", name="model", type="llm", required_at_runtime=True)
temperature = ExperimentVariable(id="var_t", name="temperature", type="float", default_value=0.2)
experiment = Experiment.create(
name="model bake-off",
dataset_id="dataset-uuid",
variables=[model, temperature],
steps=[
ExperimentStep(
step_type=ExperimentStepType.LLM,
output_column="response",
config={
"parameter_bindings": {
"model_config_id": model.ref,
"model_parameters.temperature": temperature.ref,
},
"variable_mappings": {"input": "text_column"},
},
),
],
)
run = experiment.run(variable_values={"var_model": "config-a"}) # one run
batch = experiment.run_sweep( # 2 × 2 = 4 runs, queued together
{"var_model": ["config-a", "config-b"], "var_t": [0.2, 0.8]}
)
for r in batch: # labelled "model=…, temperature=…"
print(r.run_number, r.label, r.variable_values)
Macro-graph stages
Multi-stage experiments wire ExperimentStage nodes with StageInputBinding ports. The default graph is Read Dataset → Pipeline → Write Dataset (matching the UI). Read Dataset is not a StageKind — it serializes as from_dataset_id on the Pipeline input. Write Dataset (StageKind.WRITE_DATASET) is the primary output (is_output=True). Every steps / write_dataset stage needs exactly one bound input. Stages are displayed in list order — position is auto-assigned from the list index (positions must be unique), so set position= only to override the order. Pass stage_id= to run.results() to scope a run's records to one stage's materialized dataset.
from lumenova_beacon.experiments import (
Experiment, ExperimentStage, ExperimentStep, ExperimentStepType,
StageInputBinding, StageKind,
)
experiment = Experiment.create(
name="macro-graph",
dataset_id="dataset-uuid",
stages=[
ExperimentStage(
key="pipeline",
name="Pipeline",
kind=StageKind.STEPS,
is_output=False,
inputs=[StageInputBinding(port="input", from_dataset_id="dataset-uuid")],
steps=[
ExperimentStep(
step_type=ExperimentStepType.LLM,
output_column="response",
config={
"model_config_id": "config-uuid",
"variable_mappings": {"input": "text_column"},
},
),
],
),
ExperimentStage(
key="write",
name="Write Dataset",
kind=StageKind.WRITE_DATASET,
is_output=True,
inputs=[StageInputBinding(port="input", from_stage_key="pipeline")],
config={"mode": "create", "name": "Macro Results"},
),
],
)
run = experiment.run().wait_for_completion()
stage = experiment.stages[0]
records, _ = run.results(stage_id=stage.id) # definition or run stage id
External Agents
Use an EXTERNAL_AGENT step to plug your own code — a LangGraph graph, a RAG pipeline, anything callable — into a pipeline step. The server runs every other step it can, then parks the run in WAITING_FOR_EXTERNAL; the SDK polls that run for pending inputs, dispatches each one to your callable, and writes the result back.
from lumenova_beacon.experiments import (
Experiment, ExperimentStep, ExperimentStepType, ExperimentStatus,
)
experiment = Experiment.create(
name="agent-benchmark",
dataset_id="dataset-uuid",
steps=[
ExperimentStep(
step_type=ExperimentStepType.EXTERNAL_AGENT,
output_column="agent_output",
config={
"agent_name": "My Agent",
"variable_mappings": {"input": "question"},
},
),
],
)
# Starts a new run and blocks until every pending external step of that run
# has been dispatched. `my_agent_fn` receives the resolved input and returns
# the value to write into `output_column`.
run = experiment.run(external_agents={"My Agent": my_agent_fn})
# Resume a run parked on its agent step from the same callable.
run = experiment.latest_run
if run and run.status == ExperimentStatus.WAITING_FOR_EXTERNAL:
run.continue_(external_agents={"My Agent": my_agent_fn})
Evaluation Management
Evaluations score outputs using one of three evaluator types — LLM-as-judge, code-based, or agent-as-judge — and come in two flavors selected by which source you pass:
- Trace-based (
filter_rules=...): scores spans matching a filter. Setactive=Trueand the evaluator runs continuously on new traces. - Dataset-based (
dataset_id=...): scores rows in a dataset on demand.
from lumenova_beacon.evaluations import Evaluation, Evaluator
# Reuse an existing evaluator configured in the Beacon UI
evaluator = Evaluator.get(evaluator_id="evaluator-uuid")
# Trace-based: auto-runs on new spans matching the filter
evaluation = Evaluation.create(
name="prod-quality-monitor",
evaluator_id=evaluator.id,
variable_mappings={
"question": {"data_mode": "reduced", "reduced_fields": ["input"]},
"answer": {"data_mode": "reduced", "reduced_fields": ["output"]},
},
filter_rules={"logic": "AND", "rules": [
{"column": "span_name", "operator": "contains", "value": "chat"},
]},
active=True,
)
# Dataset-based: scores rows in a dataset (run on demand)
evaluation = Evaluation.create(
name="qa-accuracy",
evaluator_id=evaluator.id,
variable_mappings={
"question": {"data_mode": "reduced", "column": "input"},
"answer": {"data_mode": "reduced", "column": "output"},
},
dataset_id="dataset-uuid",
)
filter_rules also accepts the same typed FilterRule list the trace query API uses (combined with AND and validated client-side), and trace-based evaluations can sample production traffic via sample_rate:
from lumenova_beacon.evaluations import Evaluation, FilterOperator, FilterRule
evaluation = Evaluation.create(
name="sampled-quality-monitor",
evaluator_id=evaluator.id,
variable_mappings={...},
filter_rules=[FilterRule("agent_name", FilterOperator.EQUALS, "MathAgent")],
active=True,
sample_rate=0.25, # auto-evaluate 25% of matching traffic, in (0, 1]
)
Running Evaluations
Run on a single target, in bulk on selected targets, or on everything matching the evaluation's source — and block until results land when you need them synchronously:
# Single target: exactly one of trace_id / session_id / dataset_record_id
run = evaluation.run(trace_id="trace-123")
run = evaluation.run(session_id="sess-456") # session-based evaluations
# Wait for one run (refreshes in place; FAILED does not raise — check status)
run.wait_for_completion(timeout=300)
print(run.status, run.scores_data)
# Bulk: up to 1000 selected traces/spans/sessions in one call
result = evaluation.bulk_run(
trace_ids=["t-1", "t-2"],
session_ids=["sess-1"],
items=[{"trace_id": "t-3", "span_id": "s-9"}], # span-level refinement
)
print(len(result.created), result.skipped_existing)
# Everything matching filter_rules / all dataset records
evaluation.execute()
evaluation.wait_for_runs(timeout=600) # block until no PENDING/RUNNING runs
# Rerun terminal runs in place (same run IDs, results cleared; max 500)
rerun = evaluation.rerun_runs([r.id for r in failed_runs])
print(rerun.reset_run_ids, rerun.skipped)
Extraction Engines for Variable Mappings
Each variable mapping picks an extraction engine for the field expression. Three are available:
- JSONPath — straightforward path access (
$.input.messages[0].content). Fast and familiar; right for a single key or index. - JSONata — structural transforms (filter by predicate, join, aggregate, reshape).
- Python — anything that needs general-purpose code (regex, multi-step helpers, NumPy). The expression must define a top-level
def extract(data):function.
Engines can be chained as a pipeline (e.g. JSONata → Python) when a single expression isn't enough. Caps mirrored client-side: each expr is at most 64 KiB and a pipeline is at most 32 steps.
from lumenova_beacon import VariableMapping, ProcessorStep, ExtractionEngine, ProcessorKind
# JSONPath (legacy `json_path` field — also accepted as a raw dict shape).
question = VariableMapping.jsonpath("$.messages[0].content")
# JSONata for filter/join/aggregate/reshape transforms.
answer = VariableMapping.jsonata("$join(messages[role='assistant'].content, ' ')")
# Python for general-purpose transforms.
summary = VariableMapping.python(
"def extract(data):\n"
" return data['output'].strip().lower()\n"
)
# Multi-step pipeline (JSONata → Python).
chained = VariableMapping(
data_mode="raw",
pipeline=[
ProcessorStep(engine=ExtractionEngine.JSONATA, expr="messages.content"),
ProcessorStep(
engine=ExtractionEngine.PYTHON,
kind=ProcessorKind.RESHAPE,
expr="def extract(data):\n return ' '.join(data)\n",
),
],
)
evaluation = Evaluation.create(
name="multi-engine",
evaluator_id=evaluator.id,
variable_mappings={"question": question, "answer": answer, "summary": summary},
filter_rules={"logic": "AND", "rules": []},
)
The legacy dict[str, Any] shape ({"question": {"data_mode": "raw", "json_path": "$.x"}}) keeps working unchanged. Validation runs at construction time: incompatible engine/kind pairs (e.g. jsonpath + filter), oversize expressions, and the pipeline-with-json_path anti-pattern raise ValueError before the request hits the wire. Requires the Beacon backend version that ships the Python extraction engine (lumenova-guardrails MR !337) for the python and pipeline shapes; older backends accept JSONPath only.
Querying Evaluation Runs
EvaluationRun.list() supports rich server-side filtering: by evaluation, trace, session, dataset record, status, agent attribution, run scope, score values, cluster membership, and creation date range.
from datetime import datetime, timezone
from lumenova_beacon.evaluations import EvaluationRun
# Runs attributed to an agent within a date window
runs, page = EvaluationRun.list(
agent_name="MathAgent",
scope="trace", # 'session' | 'trace' | 'span'
start_time=datetime(2026, 7, 1, tzinfo=timezone.utc),
end_time=datetime(2026, 7, 15, tzinfo=timezone.utc),
)
# Runs whose 'accuracy' score fell below 0.5
runs, page = EvaluationRun.list(score_name="accuracy", score_max=0.5)
# Per-run results: status, timing, and per-score values
for run in runs:
print(run.status, run.completed_at, run.scores_data)
Evaluation Statistics
from lumenova_beacon.evaluations import Evaluation, EvaluationRun
# Project-level rollup (typed EvaluationStats)
stats = Evaluation.stats()
print(stats.total, stats.active, stats.failed)
# Per-evaluation run statistics (raw dict, includes avg/median/p95 per score)
evaluation = Evaluation.get("evaluation-uuid", include_statistics=True)
print(evaluation.statistics)
# Per-agent rollup (typed AgentEvaluationStats): overall pass rate plus a
# per-evaluator breakdown with avg/stdev/median/p95/min/max/distribution
agent_stats = EvaluationRun.agent_stats(agent_name="MathAgent")
print(agent_stats.overall.pass_rate)
for bucket in agent_stats.per_evaluator:
for score in bucket.scores:
print(bucket.evaluator_name, score.name, score.avg, score.median, score.p95)
Evaluator Versions
Evaluators are versioned; each version snapshots the prompt template, code, and score configuration (rubric). Evaluations can pin a version via evaluator_version_id / evaluator_version_mode.
from lumenova_beacon.evaluations import Evaluator
evaluator = Evaluator.get("evaluator-uuid")
print(evaluator.current_version, evaluator.version_count)
for version in evaluator.list_versions(): # newest first
print(version.version, version.commit_message, version.created_at)
v2 = evaluator.get_version(2)
print(v2.prompt_template, v2.scores_config)
Exporting Evaluation Results
Results can leave the evaluation as an XLSX workbook, flat JSON records, or dataset records (e.g. eval failures become the next test set). The include_* flags select field groups: extracted inputs, evaluation output, scores, and run metadata.
evaluation = Evaluation.get("evaluation-uuid")
# XLSX workbook (Statistics + Results sheets, COMPLETED runs)
evaluation.export_results_xlsx(output_path="results.xlsx") # write to disk
workbook_bytes = evaluation.export_results_xlsx() # or get raw bytes
# Flat JSON records
export = evaluation.export_results(include_metadata=True)
print(export.records_total, export.records_skipped)
for record in export.records:
print(record)
# Into a dataset: create a new one...
result = evaluation.export_to_dataset(dataset_name="failure-set")
print(result.dataset_id, result.records_added, result.records_skipped)
# ...or append to an existing one (column schema is extended, never trimmed)
evaluation.export_to_dataset(dataset_id="dataset-uuid")
Definition Export / Import (Environment Promotion)
An evaluation's definition (variable mappings, filter rules, evaluator reference with inline snapshot, named refs for datasets/LLM configs/target systems) exports as a portable JSON envelope that can be imported into another project or environment — e.g. promoting from staging to production in CI:
# Source environment
envelope = evaluation.export() # dict
evaluation.export(output_path="evaluation.json") # or write to a file
# Destination environment: dry-run first
preview = Evaluation.preview_import(envelope)
print(preview.evaluator_resolution.status) # auto_matched / name_missing / ...
print(preview.unresolved_refs) # refs needing manual resolution
# Commit: bind to an existing evaluator, or create it from the envelope
imported = Evaluation.import_definition(envelope, evaluator_id="evaluator-uuid")
imported = Evaluation.import_definition(envelope, create_evaluator_from_payload=True)
Evaluator Dev Loop
Iterate on evaluators programmatically — dry-run an agent-as-judge evaluator against a real trace (nothing persisted), preview variable-mapping pipelines against sample payloads, and parse-validate Python extraction expressions:
from lumenova_beacon.evaluations import Evaluator
evaluator = Evaluator.get("evaluator-uuid")
# Agent-as-judge dry run; progress streams on the Beacon UI's
# evaluation_run:{dry_run_id} WebSocket channel
dry_run_id = evaluator.try_on_trace("trace-123", agent_config={"max_steps": 5})
# Run a pipeline server-side (including sandboxed Python steps)
preview = Evaluator.preview_pipeline(
[{"engine": "jsonata", "expr": "messages[-1].content", "kind": "extract_path"}],
data={"messages": [{"content": "hello"}]},
)
if preview.ok:
print(preview.result)
else:
print(preview.error.step_index, preview.error.kind, preview.error.message)
# Same parse rules as the runtime (syntax, AST budget, def extract(data) required)
validation = Evaluator.validate_python_expr("def extract(data):\n return data")
print(validation.ok, validation.errors)
Evaluation Clusters
Cluster an evaluation's COMPLETED runs by input to map the input landscape. EvaluationRun.list(cluster_id=...) filters runs by cluster membership:
cluster_run = evaluation.generate_clusters() # background pass (409 if active)
clusters = evaluation.get_clusters()
print(clusters.run.status, clusters.is_stale, clusters.new_completed_runs)
for cluster in clusters.clusters: # populated once completed
print(cluster.title, cluster.run_count, cluster.share_pct, cluster.keywords)
evaluation.cancel_cluster_run(cluster_run.id) # cooperative cancel
Querying and Exporting Traces
The tracing side of the SDK (above) sends spans into Beacon; this API is the opposite direction — query, search, and download the traces and spans your application exported back out of the platform. Trace and TraceSpan are read-only models with the same sync/async API shape as the other management classes (list/alist, get/aget).
from lumenova_beacon.traces import FilterOperator, FilterRule, Trace, TraceSpan
# List recent traces (free-text search, sorting, date bounds)
traces, page = Trace.list(search="checkout", sort_by="duration_ms", page_size=50)
# Structured filtering — typed rules are validated client-side and serialized
# to the backend's {"logic": "AND", "rules": [...]} format
traces, page = Trace.list(
filter_rules=[
FilterRule("duration_ms", FilterOperator.GREATER_THAN, 1000),
FilterRule("status", "equals", "ERROR"),
],
# Only traces containing at least one span matching these rules
span_filter_rules=[FilterRule("model_name", "contains", "gpt")],
)
# Cursor pagination for deep result sets
while page.has_more:
more, page = Trace.list(page_size=100, cursor=page.next_cursor)
traces.extend(more)
# Get one trace with its span tree — or hydrate a listed trace in place
trace = traces[0].fetch() # same as Trace.get(traces[0].trace_id)
for span in trace.spans: # partial TraceSpan objects (no attributes)
print(span.name, span.type, span.duration_ms)
full_span = span.fetch() # -> full TraceSpan (with attributes)
# Span-level querying, including filters on the parent trace
spans, page = TraceSpan.list(
filter_rules=[FilterRule("type", "equals", "generation")],
trace_filter_rules=[FilterRule("agent_name", "equals", "support-bot")],
)
# Full input/output — resolves blob-offloaded large values transparently
span = TraceSpan.get(spans[0].span_id)
full_input = span.full_input()
full_output = span.full_output()
# Any attribute, offload-aware; or the complete attribute dict as JSON
model = span.get_attribute("gen_ai.request.model")
import json; print(json.dumps(span.attributes, indent=2, default=str))
A raw dict is accepted anywhere a filter is (filter_rules={"logic": "AND", "rules": [...]}). Filter columns are validated by the backend (TraceValidationError on an unknown column); common ones include name, duration_ms, total_tokens, status, session_id, agent_name, environment, user_id for traces and span_name, type, model_name, input, output, langgraph_node for spans. Free-text search needs at least 3 characters, or pass a hex trace/span ID for an exact lookup.
list() returns lightweight rows; call get() (or trace.fetch()) for the full record. trace.spans holds partial TraceSpan objects (structural/summary fields, no attributes; call span.fetch() for the full record), and is None until the trace is hydrated — reading it before then logs a warning pointing you to .fetch(). Large input/output values are truncated in responses and offloaded to blob storage — span.full_input() / span.full_output() return the complete value, downloading it only when needed (span.offloaded_attributes lists what was offloaded, and span.fetch_attribute(name) fetches a raw blob directly). A trace only carries input_snippet / output_snippet; for its full input/output, read them from the root span (TraceSpan.get(trace.root_span_id).full_input()).
Exporting a trace to a file
Trace.export() returns the complete trace in one call — the same self-contained JSON document as the Beacon UI's Download button: trace metadata plus the full span hierarchy with complete attributes, no per-span fetching. Blob-offloaded attribute values are restored by the backend; anything it could not restore is reported explicitly instead of silently missing.
export = Trace.export("0123456789abcdef0123456789abcdef")
export.save(f"trace_{export.trace_id}.json") # write the document as JSON
export.raw # the exact server document (dict) — json.dump-ready
export.root_span["children"] # full span hierarchy with attributes
if not export.is_complete: # offloaded values the backend could not restore
print(export.unresolved_blob_attributes) # [(span_id, attribute_key, reason, size)]
# Batch export — up to 50 traces per call; IDs the backend dropped are explicit
exports, missing = Trace.export_batch([trace_id_1, trace_id_2])
Async variants: await Trace.aexport(...) / await Trace.aexport_batch(...). For selective reads (one span's attributes, one offloaded input) prefer the query API above — span.fetch(), span.full_input() — instead of exporting whole traces.
Agents & Insights
Register the agents your traces are attributed to, then read the AI-generated insights and recommendations Beacon produces for them — and drive the analysis runs that generate them. The agents endpoints are project-scoped: the SDK resolves your API key's bound project automatically and caches it; pass project_id= to override (required for non-project master keys).
from lumenova_beacon.agents import Agent, AgentMatchRule
# Discover agent identities seen in recent traces, then register one
found, total = Agent.discovered(days=7)
agent = Agent.create(
name="support-bot",
match_rule=AgentMatchRule(names=["support-bot"]), # or a raw dict
description="Customer support agent",
)
# Registry reads and updates
agents, page = Agent.list()
agent = Agent.get(agent.id)
agent.update(description="Handles tier-1 support chats")
# Per-agent usage rollups over a window (users/sessions/traces/tokens/cost)
report = Agent.usage(window_days=30)
for item in report.items:
print(item.agent_name, item.traces, item.cost)
Insights and recommendations (the findings ledger)
Findings are produced by Beacon's analysis runs; each is either an insight (kind, severity, frequency, confidence) or a recommendation (category, priority, suggested before/after), with evidence references back to the traces, sessions, and evaluations that support it.
# Read an agent's insights and recommendations
findings, page = agent.findings(severities=["warning", "critical"])
recs, _ = agent.findings(target_type="recommendation", sort="last_observed")
for f in findings:
print(f.severity, f.title, f.times_seen, [e.type for e in f.evidence])
# Act on a finding
finding = findings[0]
finding.add_feedback("Also happens on weekend traffic") # feeds the next analysis
observations = finding.history() # observation journal; also refreshes the finding
finding.dismiss() # hide from default listings
finding.undismiss()
# Trigger and monitor analysis
run = agent.analyze(effort="deep") # 'fast' | 'normal' | 'deep'
state = agent.analysis_state() # slim poll target; None until first analyzed
if state and state.is_stale:
agent.analyze() # incremental by default; mode="window" re-analyzes a window
runs, _ = agent.runs() # analysis run log, newest first
agent.cancel_analysis()
# Project-level fleet rollup (per-agent counts + cross-agent themes)
overview = Agent.overview()
print(overview.totals, len(overview.agents), len(overview.themes))
Async variants follow the usual a prefix (await Agent.alist(), await agent.aanalyze(), ...). When an analysis cannot start, analyze() raises AgentConflictError whose .reason carries the backend's structured cause: 'no_completed_analysis' (run a full analysis first), 'insufficient_new_data' (nothing new — pass force=True to override), or 'finding_regeneration_in_flight'.
Human Annotations
Route traces, spans, or sessions to human reviewers and read their feedback back. Annotation queues (and their score definitions) are created in the Beacon UI; the SDK covers the application-side workflows with the same sync/async API shape as the other management classes.
from lumenova_beacon.annotations import Annotation, AnnotationQueue
# List queues and inspect their score definitions
queues, page = AnnotationQueue.list(search="review")
queue = AnnotationQueue.get(queues[0].id)
for score in queue.scores_config:
print(score.name, score.type, score.required) # e.g. accuracy percent True
# Enqueue work for human review — traces, sessions, or individual spans
# (must match the queue's target_type). Adding is idempotent.
result = queue.add_items(trace_ids=[trace_id])
result = queue.add_items(session_ids=["session-1"])
result = queue.add_items(spans=[(trace_id, span_id)])
print(len(result.created), result.skipped_existing)
# List items and push feedback collected in your own app (e.g. end-user
# thumbs up/down). Scores are a plain {name: value} mapping, validated
# server-side against the queue's score definitions.
items, item_page = queue.items(status="pending")
items[0].complete({"accuracy": 0.9, "notes": "solid answer"})
# Optimistic concurrency: raise AnnotationConflictError instead of
# overwriting another annotator's concurrent edit
item = queue.get_item(items[0].id)
item.complete({"accuracy": 1.0}, expected_last_modified_at=item.last_modified_at)
# Read annotation summaries back — one per queue item referencing the target
summaries = Annotation.for_trace(trace_id) # or Annotation.for_session(...)
summaries = Trace.get(trace_id).annotations() # convenience on Trace
for s in summaries:
print(s.queue_name, s.status, {sc.name: sc.value for sc in s.scores})
Score types are percent (float in 0–1), numeric (respecting min/max bounds), categorical (one of the configured values), and text. A completion may fill a subset of scores — the item's status (pending / partially_completed / completed) is derived from which required scores are filled. Queue create/update/delete, import/export, and calibration are UI/admin workflows and intentionally not exposed. See examples/management/annotations_usage.py for a full walkthrough.
LLM Config Management
from lumenova_beacon.llm_configs import LLMConfig
config = LLMConfig.get(config_id="config-uuid")
configs, pagination = LLMConfig.list(page=1, page_size=20)
Data Masking
Automatically mask sensitive data (PII) before spans are exported:
from lumenova_beacon import BeaconClient
from lumenova_beacon.masking.integrations.beacon_guardrails import (
create_beacon_masking_function,
MaskingMode,
PIIType,
)
# Create a masking function backed by Beacon Guardrails API
masking_fn = create_beacon_masking_function(
pii_types=[PIIType.PERSON, PIIType.EMAIL_ADDRESS, PIIType.US_SSN],
mode=MaskingMode.REDACT,
)
# Pass it to the client - all span data is masked before export
client = BeaconClient(
endpoint="https://your-beacon-endpoint.lumenova.ai",
api_key="your-api-key",
masking_function=masking_fn,
)
You can also provide a custom masking function:
def my_masking_fn(text: str) -> str:
return text.replace("secret", "***")
client = BeaconClient(masking_function=my_masking_fn)
Masking applies at the exporter level to every exported span — native Beacon spans and OTel instrumentor spans (Anthropic, FastAPI, HTTPX, ...) alike, span events included. Content-bearing attribute keys (prompt, completion, input, output, ...) run your masking_function; every other string value gets a built-in deterministic PII floor (emails, phones, URLs), and technical URL keys keep their host readable for debugging.
PII the application knows structurally (a contact's name or phone) can be registered for guaranteed deterministic redaction — this catches what probabilistic NER misses:
from lumenova_beacon import register_pii_terms
register_pii_terms(contact.first_name, contact.last_name, contact.phone)
Fine-tune (or activate floor-only masking without a masking function) via SpanMaskingOptions:
from lumenova_beacon import BeaconClient, SpanMaskingOptions
client = BeaconClient(
endpoint="...",
api_key="...",
span_masking=SpanMaskingOptions(
max_export_value_chars=30_000, # truncate before masking
pii_floor=True, # deterministic emails/phones/URLs floor
mask_event_attributes=True, # GenAI semconv puts content in events
),
)
Masking fails closed: if masking a span raises, the span is exported with its attributes replaced by {"beacon.masking.failed": "true"} and its events, links and status description dropped — never raw. A span that cannot even be stripped is dropped entirely.
When the masking function is backed by Beacon Guardrails, the API calls are bounded so a slow or failing backend can never stall span export:
| Guard | Default | Behavior |
|---|---|---|
timeout |
5s | Hard per-call limit, applied to both the worker future and the HTTP request. |
max_consecutive_failures |
3 | Circuit breaker for availability failures (connection errors, 5xx, bad responses). Opens after N in a row, then half-opens every circuit_retry_seconds (default 60s) and probes with a short synthetic payload. |
max_consecutive_timeouts |
10 | Separate, higher budget for timeouts. A timeout is evidence about one payload, not about the service — but a backend that accepts connections and then hangs produces nothing else, so timeouts still trip the breaker on their own count. |
max_value_chars |
16 000 | Values above this skip the API call entirely and keep the deterministic floor only. Checked before the breaker: a value that is never sent is unaffected by the health of the service it is not being sent to. |
on_failure |
'placeholder' |
What a value becomes when masking could not run — 'placeholder' substitutes <MASKING_UNAVAILABLE>, 'passthrough' returns the input. |
masking_fn = create_beacon_masking_function(
guardrails_endpoint="https://guardrails.internal", # optional: its own host
pii_types=[PIIType.PERSON],
timeout=5.0,
max_consecutive_failures=3,
max_value_chars=16_000,
)
Read timeout and max_value_chars as a pair. Values under the gate are sent and must return within timeout; values over it are never sent at all. If a deployment's sanitize latency at gate-sized payloads exceeds timeout, every value in that band fails consistently while larger ones pass through untouched — risk stops being monotonic in size. Size both against the endpoint's latency-vs-size curve, and prefer pointing guardrails_endpoint at the fastest reachable Guardrails host.
Keep max_value_chars at or above SpanMaskingOptions.max_export_value_chars (30 000). The two default to different values and are configured independently, so anything in the gap exports at full length having skipped the masking function entirely. The SDK logs this once when the masker is installed.
on_failure='passthrough' is worth considering when unreadable traces cost more than the residual risk — but know what it ships. The deterministic floor is regex only: emails, URLs, phones and registered terms are redacted, while PERSON and US_SSN — two of the four default pii_types — are NER-only and would go out unredacted. The default stays fail-closed.
Note: masking installs on the exporters Beacon creates. If Beacon attaches to a TracerProvider someone else already configured (non-isolated, provider pre-existing), there is no Beacon exporter to wrap — the SDK logs a warning and those spans export unmasked. Use isolated=True, or initialize Beacon before the other provider.
Controlling Span Noise
Instrumented processes emit spans nobody wants to look at — ASGI transport plumbing, health-check requests, a client library's internal chatter. span_filter drops them before export:
from lumenova_beacon import BeaconClient, SpanFilter
client = BeaconClient(
endpoint="...",
api_key="...",
span_filter=SpanFilter(
drop_name_globs=["BigQuery.*", "*healthz*"],
drop_asgi_internals=True,
),
)
Suppress at the source where you can
Filtering here is the last resort: by the time a span reaches the exporter it has already been created, sampled and buffered. Where the instrumentor can be told not to emit it, do that instead — it costs nothing:
from lumenova_beacon import instrument_fastapi
# exclude_spans defaults to ('receive', 'send') — the ASGI plumbing spans
# are never created, so there is nothing to filter.
instrument_fastapi(app)
The same applies to MCP protocol noise: BeaconMCPMiddleware already defaults capture_list_operations, capture_ping and capture_notifications to False, so tools/list polling and keepalives produce no spans at all. Reach for span_filter when the emitter is not yours to configure — an app instrumented elsewhere, a library that spans its own internals, or noise you only identified after the fact.
Rules
| Rule | Effect |
|---|---|
drop_name_globs |
Drop spans whose name matches any fnmatch pattern (*, ?, [seq]), case-insensitively. |
keep_name_globs |
When non-empty, drop every span matching none of these — a whitelist. |
drop_parentless |
Drop spans with no parent (see the caveat below). |
drop_asgi_internals |
Drop * http send / * http receive and the websocket pair. |
Patterns match the whole span name, so "contains" needs explicit wildcards (*healthz*, not healthz). Rules are ANDed — a span is exported only if it survives all of them — and drop rules win over keep_name_globs.
Any callable works in the same parameter, so rules the presets don't cover stay a one-liner:
# `attributes` is None on a span that carries none, hence the `or {}`.
client = BeaconClient(
span_filter=lambda span: (span.attributes or {}).get("http.route") != "/metrics"
)
A predicate that raises keeps the span, and so does one that returns anything other than True/False — a predicate that falls off the end returns None, and coercing that to "drop" would silently delete all your telemetry. Noise control must never be the reason real telemetry goes missing. Both cases log a WARNING naming the span, repeated at most once a minute per process.
Drops themselves are accounted for: each batch logs how many spans it dropped at DEBUG, and a batch the filter empties completely logs a WARNING. An over-broad rule — a keep-list matching none of the names your instrumentor actually emits — should never be indistinguishable from a dead exporter.
The drop_parentless caveat
A parentless span is either a trace root — an instrumentor's SERVER span, a queue consumer, a top-level script — or noise from background work that runs outside any request (a poller, a health check, an HTTP call from a heartbeat thread), each of which arrives as its own one-span trace.
The two are indistinguishable from the span alone, so drop_parentless=True drops both. Two things follow:
- It hides broken context propagation. A span orphaned across an async boundary, a raw
threading.Threador a queue hop looks exactly like intentional background noise. Dropping it removes the evidence instead of fixing the cause. - It drops legitimate roots. Beacon materializes trace-level fields (session, agent, trace name) from the parentless root, so a trace whose root is dropped never materializes them.
There is exactly one shape where the rule is safe: a process behind an upstream that always propagates traceparent, so every real trace root carries a remote parent and only background work is parentless. Anywhere else — a worker whose entry points are native Beacon spans, a service that also receives uninstrumented traffic — it deletes real traces. Naming the noise with drop_name_globs is safer whenever you can name it.
The same applies to keep_name_globs: a whitelist is ANDed like any other rule, so it has to cover your trace roots too. keep_name_globs=["llm.*"] drops the GET /chat SERVER span that roots the trace.
Where it applies
span_filter is independent of masking — setting it alone installs the exporter wrapper as a pure filter, leaving every attribute untouched (no PII floor, no truncation). It covers every exporter Beacon creates: the primary one, add_span_exporter(), and BEACON_EXTRA_OTLP_ENDPOINTS. When masking is also configured, filtering runs first, so a dropped span never pays for a masking pass.
It carries the same install-point caveat as masking: if Beacon attaches to a TracerProvider someone else already configured, there is no Beacon exporter to wrap and the SDK logs a warning saying spans are not filtered. Use isolated=True, or initialize Beacon first. Only the first client in a process registers the Beacon pipeline, so a second client's span_filter needs isolated=True to install at all.
One filtering-specific gap: database_callback hands native spans straight to your callback, bypassing the exporter, so the filter applies to instrumentor spans only. Beacon warns when both are set — filter inside the callback to cover both. (Masking has no such gap; it is re-applied imperatively on that path.)
Guardrails
Apply content guardrail policies via the Beacon API:
from lumenova_beacon.guardrails import Guardrail
guardrail = Guardrail(guardrail_id="guardrail-uuid")
# Sync
result = guardrail.apply("some user input")
# Async
result = await guardrail.aapply("some user input")
Optional metadata
Pass an optional metadata dict to enable retrieval/grounding-aware policies:
result = guardrail.apply(
"Paris is the capital of France.",
metadata={
"query": "What is the capital of France?",
"context": [
"Paris is the capital and largest city of France.",
"France is a country in Western Europe.",
],
},
)
metadatais optional — omit it entirely if you only need to validatetext. Bothqueryandcontextare also individually optional withinmetadata.queryis the user's original question; it is forwarded to evaluation policies that score relevance/grounding against the model output.contextis the list of retrieved document texts the model used to answer (typical RAG flow). Pass the actual content of each chunk, not links to it. Each entry is sanitized alongside the maintextand round-trips into the response (outputs[].input.metadata.context,outputs[].output.metadata.context).
API Reference
Main Exports
from lumenova_beacon import (
BeaconClient, # Main client
BeaconConfig, # Configuration class
get_client, # Get current client singleton
trace, # Tracing decorator
# Integrations (lazy-loaded)
BeaconLangGraphHandler, # LangChain/LangGraph
BeaconLangGraphConfig, # LangGraph configuration (adds support for interruptions)
BeaconStrandsHooks, # Strands Agents (hook provider, recommended)
BeaconStrandsHandler, # Strands Agents (callback handler, legacy)
BeaconCrewAIListener, # CrewAI
BeaconMCPMiddleware, # MCP server (FastMCP middleware)
BeaconMCPClient, # MCP client (FastMCP client with context propagation)
BeaconLiteLLMLogger, # LiteLLM
# Ambient run context
set_session, get_session, clear_session,
set_agent, get_agent, clear_agent,
# Cross-process context propagation
inject_trace_context, # Write traceparent + session into a carrier
extract_trace_context, # Read one back (-> RemoteContext)
start_consumer_span, # Open a span for a message, tied to its producer
# Governance (lazy-loaded)
BeaconLangGraphAgent, # LangGraph wrapper with violation_mode dispatch
BeaconLangGraphGovernanceHandler, # LangChain/LangGraph governance callback handler
GovernanceConfig, # Governance configuration
)
from lumenova_beacon.governance import governance # Function decorator
from lumenova_beacon.datasets import Dataset, DatasetRecord
from lumenova_beacon.prompts import Prompt
from lumenova_beacon.experiments import Experiment
from lumenova_beacon.evaluations import (
Evaluation,
EvaluationRun,
Evaluator,
EvaluatorVersion, # Immutable evaluator version snapshot
EvaluationStats, # Project-level evaluation rollup
AgentEvaluationStats, # Per-agent stats (overall + per-evaluator scores)
)
from lumenova_beacon.agents import Agent, AgentFinding # Agent registry + insights
from lumenova_beacon.annotations import Annotation, AnnotationQueue
from lumenova_beacon.llm_configs import LLMConfig
from lumenova_beacon.guardrails import Guardrail
from lumenova_beacon.types import SpanKind, StatusCode, SpanType
BeaconClient
client = BeaconClient(
endpoint: str | None = None,
api_key: str | None = None,
file_directory: str | None = None,
session_id: str | None = None,
user_email: str | None = None,
user_name: str | None = None,
user_id: str | None = None,
service_name: str | None = None,
environment: str | None = None,
auto_instrument_opentelemetry: bool = True,
isolated: bool = False,
otel_context_propagation: bool | None = None,
auto_instrument_litellm: bool = False,
masking_function: Callable | None = None,
span_masking: SpanMaskingOptions | None = None,
span_filter: Callable[[ReadableSpan], bool] | None = None,
verify: bool | str | None = None,
proxies: dict[str, str] | str | None = None,
cert: str | tuple[str, str] | None = None,
eager_export: bool | str | None = None, # True/'all', 'root', or False/'off'
record_stacktrace: bool = False,
)
# Methods
span = client.create_span(name, kind, span_type, session_id)
ctx = client.trace(name, kind, span_type) # Context manager (sync & async)
client.export_span(span) # Export a single span
client.export_spans(spans) # Export multiple spans
client.flush() # Flush pending spans
Dataset
# Class methods (sync - simple names)
dataset = Dataset.create(name: str, description: str | None = None, column_schema: list[dict[str, Any]] | None = None)
dataset = Dataset.get(dataset_id: str, include_records: bool = False)
datasets, pagination = Dataset.list(page=1, page_size=20, search=None)
# Class methods (async - 'a' prefix)
dataset = await Dataset.acreate(...)
dataset = await Dataset.aget(...)
datasets, pagination = await Dataset.alist(...)
# Instance methods (sync - simple names)
dataset.save()
dataset.update(name=None, description=None)
dataset.delete()
record = dataset.create_record(data: dict[str, Any])
dataset.bulk_create_records(records: list[dict])
records, pagination = dataset.list_records(page=1, page_size=50)
# Instance methods (async - 'a' prefix)
await dataset.asave()
await dataset.aupdate(...)
await dataset.adelete()
record = await dataset.acreate_record(...)
await dataset.abulk_create_records(...)
records, pagination = await dataset.alist_records(...)
# Properties
dataset.id
dataset.name
dataset.description
dataset.record_count
dataset.created_at
dataset.updated_at
dataset.column_schema
DatasetRecord
# Class methods (sync - simple names)
record = DatasetRecord.get(dataset_id: str, record_id: str)
records, pagination = DatasetRecord.list(dataset_id: str, page=1, page_size=50)
# Class methods (async - 'a' prefix)
record = await DatasetRecord.aget(...)
records, pagination = await DatasetRecord.alist(...)
# Instance methods (sync - simple names)
record.save()
record.update(data: dict[str, Any] | None = None)
record.delete()
# Instance methods (async - 'a' prefix)
await record.asave()
await record.aupdate(...)
await record.adelete()
# Properties
record.id
record.dataset_id
record.data # dict[str, Any] - flexible column data
record.created_at
record.updated_at
Prompt
# Class methods (sync - simple names)
prompt = Prompt.create(name, *, template=None, messages=None, description=None, tags=None, category=None, message=None)
prompt = Prompt.get(prompt_id=None, *, name=None, label="latest", version=None)
prompts, pagination = Prompt.list( # returns (list[Prompt], PaginatedResponse)
page=1, page_size=50,
tags=None, # list[str] (matches prompts having ANY of these tags),
# or an any_of()/all_of() expression for AND/OR combos
# e.g. all_of("support", any_of("prod", "staging"))
search=None, prompt_type=None,
category=None, # str, or Prompt.UNCATEGORIZED for prompts with no category
sort_by=None, # 'created_at' | 'updated_at' | 'name' (server default: 'created_at')
sort_order=None, # 'asc' | 'desc' (server default: 'desc')
)
prompts = Prompt.bulk_get(prompt_ids=None, category=None) # one of; category may be Prompt.UNCATEGORIZED
categories = Prompt.categories() # -> list[str] of category names in use
tags = Prompt.list_tags() # -> list[str] of tags in use (sorted)
# Class methods (async - 'a' prefix)
prompt = await Prompt.acreate(...)
prompt = await Prompt.aget(...)
prompts, pagination = await Prompt.alist(...)
prompts = await Prompt.abulk_get(...)
categories = await Prompt.acategories()
tags = await Prompt.alist_tags()
# Instance methods (sync - simple names)
prompt.update(name=None, description=None, tags=None, category=None)
prompt.delete()
new_version = prompt.publish(template=None, messages=None, message="")
prompt.set_label(label: str, version: int | None = None)
labels = prompt.list_labels() # -> list[PromptLabel]
label = prompt.get_label(name: str) # -> PromptLabel (raises PromptNotFoundError if absent)
# Instance methods (async - 'a' prefix)
await prompt.aupdate(...)
await prompt.adelete()
new_version = await prompt.apublish(...)
await prompt.aset_label(...)
labels = await prompt.alist_labels()
label = await prompt.aget_label(name)
# Rendering (always sync)
result = prompt.format(**kwargs)
result = prompt.compile(variables: dict)
template = prompt.to_template() # Convert to Python f-string format
lc_prompt = prompt.to_langchain() # Convert to LangChain template
# Properties
prompt.id
prompt.name
prompt.type # "text" or "chat"
prompt.version
prompt.template # For TEXT prompts
prompt.messages # For CHAT prompts
prompt.labels # list[str]
prompt.tags # list[str]
Span
span = Span(name, kind, span_type)
# Lifecycle
span.start()
span.end(status_code=StatusCode.OK)
# Status
span.set_status(StatusCode.ERROR, "description")
span.record_exception(exc: Exception)
# Attributes
span.set_attribute("key", value)
span.set_attributes({"k1": "v1", "k2": "v2"})
span.set_input(data: dict)
span.set_output(data: dict)
span.set_metadata("key", value)
# Properties
span.trace_id
span.span_id
span.parent_id
span.name
span.kind
span.span_type
Type Enums
from lumenova_beacon.types import SpanKind, StatusCode, SpanType
# SpanKind
SpanKind.INTERNAL
SpanKind.SERVER
SpanKind.CLIENT
SpanKind.PRODUCER
SpanKind.CONSUMER
# StatusCode
StatusCode.UNSET
StatusCode.OK
StatusCode.ERROR
# SpanType
SpanType.SPAN
SpanType.GENERATION
SpanType.CHAIN
SpanType.TOOL
SpanType.RETRIEVAL
SpanType.AGENT
SpanType.FUNCTION
SpanType.REQUEST
SpanType.SERVER
SpanType.TASK
SpanType.CACHE
SpanType.EMBEDDING
SpanType.HANDOFF
SpanType.CONDITIONAL
Error Handling
All exceptions inherit from BeaconError. Key exception types:
ConfigurationError— invalid configurationTransportError(HTTPTransportError,FileTransportError) — export failuresGovernanceViolationError— policy blocked an action (includesmessage,policies,latency_ms)DatasetError,PromptError,ExperimentError,EvaluationError,TraceError,AnnotationError,AgentError— resource-specific errors (each withNotFoundandValidationvariants; 409s raiseAnnotationConflictError/AgentConflictError, the latter carrying a structured.reason)
Retry Logic
All HTTP operations automatically retry up to 3 times with exponential backoff:
from lumenova_beacon.exceptions import PromptNetworkError
try:
prompt = Prompt.get(name="my-prompt")
except PromptNetworkError as e:
# Failed after 3 automatic retries
print(f"Network error: {e}")
except PromptNotFoundError as e:
# Prompt doesn't exist
print(f"Not found: {e}")
Graceful Degradation
from lumenova_beacon import BeaconClient
# Disable tracing in development
client = BeaconClient(enabled=False)
# Tracing becomes no-op when disabled
@trace
def my_function():
return "result" # No tracing overhead
License
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file lumenova_beacon-2.14.2.tar.gz.
File metadata
- Download URL: lumenova_beacon-2.14.2.tar.gz
- Upload date:
- Size: 1.6 MB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c07d70e43db2188757b35353f0803eb1324b2ca8fdb1abd04accbdc96fa89f5f
|
|
| MD5 |
48702926c8fb0c487b8e2492aa0ecbf0
|
|
| BLAKE2b-256 |
433a46603b6fe64e205b5ba09e719fa1f5d3dc1fb0588e9dba4bb9bd4ee48d64
|
File details
Details for the file lumenova_beacon-2.14.2-py3-none-any.whl.
File metadata
- Download URL: lumenova_beacon-2.14.2-py3-none-any.whl
- Upload date:
- Size: 528.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4c482549d1cf536b4916a720992e3dc0cd74a7090678839ccb962654a3f5cb44
|
|
| MD5 |
8c73f774995f6b334e7439c73ec95af3
|
|
| BLAKE2b-256 |
01cff1728cddeb6178d715c5fbbd3ee818fda79cd428f11b9391149786d64f4f
|