Skip to main content

Traces and analytics, without secrets

Both SDKs report each protocol stage they complete — discovery, a Session start or renewal, each sign-in step, each conversation call, each operation read, confirmation or cancellation — to a pap.telemetry sink the host contributes through Reactor. Without a sink nothing is emitted, and nothing changes.

Each stage is one TelemetryEvent: its name, its stage, ok or error, its duration, and attributes. One event is what a trace exporter turns into a span and what an analytics pipeline counts, so traces and analytics see the same data.

Redacted by construction​

Attributes come from an allowlist (TELEMETRY_ATTRIBUTES), and each value must pass its rule; a value that fails is dropped, and a name outside the list is a programming error the SDK refuses:

AttributeValue
pap.stagediscovery, session, sign_in, conversation, or operation
pap.outcomeok or error
pap.errorthe refusal's error code, a lowercase identifier; unexpected for anything else
pap.company.domaina DNS name
pap.company.issueran https origin with at most a plain path: no user information, query, or fragment
pap.sign_in.methoddirect, device, mediated, or account_token
pap.sign_in.statusthe step's status, a lowercase identifier (pending, complete, signed_out, …)
pap.session.signed_ina flag
pap.scopes.granted, pap.scopes.missingcounts
pap.conversation.statusa lowercase identifier (idle, closed, …)
pap.conversation.eventsa count
pap.operation.state, pap.operation.statuslowercase identifiers (proposed, succeeded, confirmed, terms_changed, …)
pap.operation.revisiona count

No rule admits a token, an assertion, a DPoP proof, a code, a cookie, a password, a mediated field, a Session, conversation or operation ID, a pairwise User ID, or a URL with a query: the SDK never offers them, and none could pass. An error event carries the refusal's code only, never its message or the company's answer. A sink that throws is logged by its exception type alone and never fails the protocol call; two sinks at the winning order fail closed, as every optional point does.

The canary suites hold this to a real run in both languages (test_canaries.py, canaries.test.mjs): every Reactor of the demo worlds has a sink, and no credential exchanged on the wire may appear in any event, as analytics JSON or as the span the OpenTelemetry adapter starts. See Security.

EventStage
pap.discovery.discoverdiscovery
pap.session.start, pap.session.renewsession
pap.sign_in.direct.begin, pap.sign_in.direct.completesign_in
pap.sign_in.device.begin, pap.sign_in.device.pollsign_in
pap.sign_in.mediated.sign_in, pap.sign_in.mediated.submit_codesign_in
pap.sign_in.account_token.refresh, pap.sign_in.account_token.revokesign_in
pap.conversation.start, .send, .read_events, .start_direct, .handoff, .closeconversation
pap.operation.read, .confirm, .canceloperation

OpenTelemetry​

The OpenTelemetry adapter makes each event a span of the host's tracer, under the span current when the stage ran; a failed stage's span has an error status and no description. The dependency stays optional: in Python install the telemetry extra (opentelemetry-api); in TypeScript pass any @opentelemetry/api tracer, which the adapter uses structurally.

import asyncio

from opentelemetry import trace
from reactor import PluginManifest

from personal_agent_protocol import (
TELEMETRY_SINKS,
DiscoveryClient,
OpenTelemetrySink,
build_pap_reactor,
)


class Telemetry:
"""The host's sink: the SDK's protocol stages as spans of the host's tracer."""

def provide_contributions(self, contributions) -> None:
sink = OpenTelemetrySink(trace.get_tracer("my-personal-agent"))
contributions.contribute(TELEMETRY_SINKS, sink, contribution_id="otel", order=50)


async def main() -> None:
reactor, _ = build_pap_reactor(
plugins=[(PluginManifest(name="telemetry", version="1"), Telemetry())]
)
async with DiscoveryClient(reactor=reactor) as client:
await client.discover("example.com") # one span: pap.discovery.discover


asyncio.run(main())
import {
TelemetrySinks,
buildPapReactor,
discoverCompany,
openTelemetrySink,
} from "@datalayer/personal-agent-protocol";

// `tracer` is the host's OpenTelemetry tracer: trace.getTracer("my-personal-agent").
const reactor = buildPapReactor({}, [
{
name: "telemetry",
contributes: [
{ point: TelemetrySinks, value: openTelemetrySink(tracer), options: { id: "otel", order: 50 } },
],
},
]);
const company = await discoverCompany("example.com", { reactor }); // one span: pap.discovery.discover

Analytics​

For analytics, contribute any object with record(event): event.as_dict() (JSON.stringify(event) in TypeScript) is the event as JSON, ready for a pipeline. RecordingTelemetrySink keeps events in memory, for tests. The telemetry Pydantic AI example hands a model the events of a whole journey with Manzanita, which is safe precisely because they hold nothing secret.