Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ Run the built-in Langfuse fixture through the current end-to-end workflow:
```bash
uv sync
uv run agentprof init
export AGENTPROF_HASH_SALT='dev-salt-value-at-least-16-bytes'
uv run agentprof doctor
AGENTPROF_HASH_SALT=dev-salt uv run agentprof import langfuse-export \
uv run agentprof import langfuse-export \
--observations tests/fixtures/langfuse_observations.json
uv run agentprof normalize
uv run agentprof analyze retry-loops
Expand All @@ -85,7 +86,7 @@ The workflow creates local files only. After `agentprof report generate`, the de
uv run agentprof init
```

This creates `agentprof.yml`, local workspace directories under `.agentprof/`, and the DuckDB store at `.agentprof/data/agentprof.duckdb`.
This creates `agentprof.yml`, local workspace directories under `.agentprof/`, `.agentprof/.gitignore`, and the DuckDB store at `.agentprof/data/agentprof.duckdb`.

2. Configure privacy.

Expand All @@ -95,7 +96,7 @@ By default, `agentprof.yml` sets `privacy.hash_inputs: true`, so imports that co
export AGENTPROF_HASH_SALT='replace-with-a-stable-secret-for-this-project'
```

Use a stable per-project salt if you want hashes to remain comparable across repeated imports. Do not commit the salt.
Use a stable per-project salt of at least 16 bytes if you want hashes to remain comparable across repeated imports. Do not commit the salt.

3. Import a Langfuse observations export.

Expand Down Expand Up @@ -181,7 +182,7 @@ The costed multi-agent fixture shows the current baseline-estimate story without

```bash
uv run agentprof store reset --yes
AGENTPROF_HASH_SALT=dev-salt uv run agentprof import langfuse-export \
AGENTPROF_HASH_SALT=dev-salt-value-at-least-16-bytes uv run agentprof import langfuse-export \
--observations tests/fixtures/langfuse_multi_agent_observations.json
uv run agentprof normalize
uv run agentprof analyze multi-agent-waste --baseline-ratio 0.50
Expand Down Expand Up @@ -370,7 +371,7 @@ Useful local smoke workflow:

```bash
uv run agentprof store reset --yes
AGENTPROF_HASH_SALT=dev-salt uv run agentprof import langfuse-export \
AGENTPROF_HASH_SALT=dev-salt-value-at-least-16-bytes uv run agentprof import langfuse-export \
--observations tests/fixtures/langfuse_observations.json
uv run agentprof normalize
uv run agentprof analyze retry-loops
Expand Down
44 changes: 41 additions & 3 deletions agentprof/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
AgentProfConfig,
ensure_workspace_dirs,
load_config,
write_workspace_gitignore,
write_default_config,
)
from agentprof.cost.runner import build_cost_ledger
Expand All @@ -32,7 +33,7 @@
import_langfuse_export,
)
from agentprof.normalize.runner import normalize_store
from agentprof.privacy.hashing import MissingSaltError
from agentprof.privacy.hashing import MissingSaltError, salt_from_env
from agentprof.report.runner import DEFAULT_REPORT_DIR, generate_report, validate_report_id
from agentprof.store.duckdb_store import DuckDBStore

Expand All @@ -48,6 +49,7 @@ class MultiAgentBaselineMode(StrEnum):


console = Console()
error_console = Console(stderr=True)
REPORT_SHOW_MAX_BYTES = 1_048_576
REPORT_SHOW_CHUNK_SIZE = 64 * 1024
app = typer.Typer(
Expand Down Expand Up @@ -75,7 +77,7 @@ def _version_callback(value: bool) -> None:

def _load_config_or_exit() -> AgentProfConfig:
try:
return load_config()
config = load_config()
except FileNotFoundError as exc:
console.print("[red]agentprof.yml was not found.[/red]")
console.print("Run `agentprof init` to create the local workspace.")
Expand All @@ -84,6 +86,8 @@ def _load_config_or_exit() -> AgentProfConfig:
console.print("[red]agentprof.yml is invalid.[/red]")
console.print(str(exc))
raise typer.Exit(code=2) from exc
_warn_if_raw_io_enabled(config)
return config


@app.callback()
Expand Down Expand Up @@ -118,6 +122,8 @@ def init(

for path in ensure_workspace_dirs():
created.append(str(path))
if write_workspace_gitignore(force=force):
created.append(str(APP_DIR / ".gitignore"))

config = _load_config_or_exit()
store = DuckDBStore(config.store.path)
Expand Down Expand Up @@ -161,6 +167,13 @@ def doctor() -> None:
console.print(f"Store/config check failed: {exc}")
raise typer.Exit(code=2) from exc

privacy_errors = _privacy_doctor_errors(config)
if privacy_errors:
console.print("[red]AgentProf workspace has privacy configuration issues.[/red]")
for message in privacy_errors:
console.print(f" {message}")
raise typer.Exit(code=2)

console.print("[green]AgentProf workspace looks ready.[/green]")


Expand Down Expand Up @@ -568,7 +581,10 @@ def import_langfuse_export_command(
file_format=file_format,
)
except MissingSaltError as exc:
console.print("[red]Cannot hash Langfuse I/O without a configured salt.[/red]")
console.print(
"[red]Cannot hash Langfuse identifiers or I/O without a configured salt.[/red]"
)
console.print(str(exc))
console.print(
f"Set `{config.privacy.hmac_salt_env}` or disable `privacy.hash_inputs`."
)
Expand Down Expand Up @@ -608,6 +624,28 @@ def _parse_decimal_option(value: str, name: str) -> Decimal:
raise ValueError(f"{name} must be a decimal value") from exc


def _warn_if_raw_io_enabled(config: AgentProfConfig) -> None:
if config.privacy.store_raw_io:
error_console.print(
"[yellow]WARNING: privacy.store_raw_io is true; raw Langfuse inputs "
"and outputs may be written to disk.[/yellow]"
)


def _privacy_doctor_errors(config: AgentProfConfig) -> list[str]:
errors: list[str] = []
if config.privacy.store_raw_io:
errors.append(
"privacy.store_raw_io is true; raw inputs and outputs may be persisted."
)
if config.privacy.hash_inputs:
try:
salt_from_env(config.privacy.hmac_salt_env)
except MissingSaltError as exc:
errors.append(str(exc))
return errors


def _validate_report_output_dir(output_dir: Path) -> None:
reports_root = _resolve_path(DEFAULT_REPORT_DIR)
output_path = _resolve_path(output_dir)
Expand Down
15 changes: 15 additions & 0 deletions agentprof/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
CONFIG_FILE = Path("agentprof.yml")
APP_SUBDIRS = ("data", "baselines", "reports", "cache")
DEFAULT_STORE_PATH = APP_DIR / "data" / "agentprof.duckdb"
WORKSPACE_GITIGNORE = APP_DIR / ".gitignore"
WORKSPACE_GITIGNORE_CONTENT = "*\n!.gitignore\n"

DEFAULT_CONFIG = """project:
name: tracer
Expand Down Expand Up @@ -122,6 +124,19 @@ def write_default_config(path: Path = CONFIG_FILE, *, force: bool = False) -> bo
return True


def write_workspace_gitignore(
path: Path = WORKSPACE_GITIGNORE,
*,
force: bool = False,
) -> bool:
if path.exists() and not force:
return False

path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(WORKSPACE_GITIGNORE_CONTENT, encoding="utf-8")
return True


def load_config(path: Path = CONFIG_FILE) -> AgentProfConfig:
if not path.exists():
raise FileNotFoundError(path)
Expand Down
40 changes: 37 additions & 3 deletions agentprof/ingest/langfuse_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,21 @@
from typing import Any

from agentprof.config import AgentProfConfig
from agentprof.privacy.hashing import content_hash, retry_fingerprint, salt_from_env
from agentprof.privacy.hashing import (
content_hash,
retry_fingerprint,
salt_from_env,
session_id_hash,
user_id_hash,
)
from agentprof.privacy.redactor import evidence_preview, redact_value, rules_from_config
from agentprof.store.duckdb_store import DuckDBStore, RawSpanRecord


LANGFUSE_SOURCE = "langfuse"
INPUT_FIELD = "input"
OUTPUT_FIELD = "output"
IDENTITY_FIELDS = ("userId", "user_id", "sessionId", "session_id")


class LangfuseExportFormat(StrEnum):
Expand Down Expand Up @@ -118,10 +125,20 @@ def sanitize_observation_payload(
rules = rules_from_config(privacy.redact)
raw_input = payload.get(INPUT_FIELD)
raw_output = payload.get(OUTPUT_FIELD)
salt = _hash_salt(config, raw_input=raw_input, raw_output=raw_output)
raw_user_id = _string_field(payload, "userId", "user_id")
raw_session_id = _string_field(payload, "sessionId", "session_id")
salt = _hash_salt(
config,
raw_input=raw_input,
raw_output=raw_output,
raw_user_id=raw_user_id,
raw_session_id=raw_session_id,
)

payload.pop(INPUT_FIELD, None)
payload.pop(OUTPUT_FIELD, None)
for key in IDENTITY_FIELDS:
payload.pop(key, None)
payload = redact_value(payload, rules)
if privacy.store_raw_io:
if raw_input is not None:
Expand All @@ -132,6 +149,8 @@ def sanitize_observation_payload(
payload["_agentprof_privacy"] = _privacy_metadata(
raw_input=raw_input,
raw_output=raw_output,
raw_user_id=raw_user_id,
raw_session_id=raw_session_id,
config=config,
salt=salt,
)
Expand All @@ -142,6 +161,8 @@ def _privacy_metadata(
*,
raw_input: Any,
raw_output: Any,
raw_user_id: str | None,
raw_session_id: str | None,
config: AgentProfConfig,
salt: bytes | None,
) -> dict[str, Any]:
Expand All @@ -152,6 +173,8 @@ def _privacy_metadata(
"redacted_io_stored": privacy.store_redacted_io,
"input_hash": None,
"output_hash": None,
"user_hash": None,
"session_hash": None,
"input_retry_fingerprint": None,
"output_retry_fingerprint": None,
"input_preview": None,
Expand All @@ -165,6 +188,10 @@ def _privacy_metadata(
if raw_output is not None:
metadata["output_hash"] = content_hash(raw_output, salt)
metadata["output_retry_fingerprint"] = retry_fingerprint(raw_output, salt)
if raw_user_id is not None:
metadata["user_hash"] = user_id_hash(raw_user_id, salt)
if raw_session_id is not None:
metadata["session_hash"] = session_id_hash(raw_session_id, salt)

if privacy.store_redacted_io:
if raw_input is not None:
Expand All @@ -188,8 +215,15 @@ def _hash_salt(
*,
raw_input: Any,
raw_output: Any,
raw_user_id: str | None,
raw_session_id: str | None,
) -> bytes | None:
if not config.privacy.hash_inputs or (raw_input is None and raw_output is None):
if not config.privacy.hash_inputs or (
raw_input is None
and raw_output is None
and raw_user_id is None
and raw_session_id is None
):
return None
return salt_from_env(config.privacy.hmac_salt_env)

Expand Down
4 changes: 3 additions & 1 deletion agentprof/normalize/langfuse.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"metadata",
"_agentprof_privacy",
}
TRACE_ATTRIBUTE_FIELDS = ("sessionId", "userId", "environment", "version")
TRACE_ATTRIBUTE_FIELDS = ("environment", "version")
LANGFUSE_TYPE_TO_SPAN_TYPE: dict[str, SpanType] = {
"AGENT": "agent",
"EMBEDDING": "embedding",
Expand Down Expand Up @@ -114,6 +114,8 @@ def map_langfuse_raw_span(row: RawSpanRow) -> NormalizedSpan:
output_retry_fingerprint=_string_field(privacy, "output_retry_fingerprint"),
input_preview=_string_field(privacy, "input_preview"),
output_preview=_string_field(privacy, "output_preview"),
session_id=_string_field(privacy, "session_hash"),
user_hash=_string_field(privacy, "user_hash"),
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
Expand Down
12 changes: 10 additions & 2 deletions agentprof/normalize/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ def build_normalized_traces(spans: list[NormalizedSpan]) -> list[NormalizedTrace
source=trace_spans[0].source,
root_span_id=root.span_id if root else None,
root_name=root.name if root else None,
session_id=_first_attribute(trace_spans, "sessionId"),
user_hash=_first_attribute(trace_spans, "userId"),
session_id=_first_span_value(trace_spans, "session_id"),
user_hash=_first_span_value(trace_spans, "user_hash"),
environment=_first_attribute(trace_spans, "environment"),
version=_first_attribute(trace_spans, "version"),
start_time=start_time,
Expand Down Expand Up @@ -170,6 +170,14 @@ def _first_attribute(spans: list[NormalizedSpan], key: str) -> str | None:
return None


def _first_span_value(spans: list[NormalizedSpan], name: str) -> str | None:
for span in spans:
value = getattr(span, name)
if value not in (None, ""):
return str(value)
return None


def _trace_duration_ms(start_time: datetime | None, end_time: datetime | None) -> float | None:
if not start_time or not end_time:
return None
Expand Down
2 changes: 2 additions & 0 deletions agentprof/normalize/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ class NormalizedSpan(BaseModel):
output_retry_fingerprint: str | None = None
input_preview: str | None = None
output_preview: str | None = None
session_id: str | None = None
user_hash: str | None = None
input_tokens: int | None = None
output_tokens: int | None = None
total_tokens: int | None = None
Expand Down
18 changes: 17 additions & 1 deletion agentprof/privacy/hashing.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
from typing import Any


MIN_HMAC_SALT_BYTES = 16


DEFAULT_VOLATILE_KEYS = frozenset(
{
"created_at",
Expand Down Expand Up @@ -56,7 +59,12 @@ def salt_from_env(env_name: str) -> bytes:
value = os.getenv(env_name)
if not value:
raise MissingSaltError(f"{env_name} is not set")
return value.encode("utf-8")
salt = value.encode("utf-8")
if len(salt) < MIN_HMAC_SALT_BYTES:
raise MissingSaltError(
f"{env_name} must be at least {MIN_HMAC_SALT_BYTES} bytes"
)
return salt


def hmac_sha256_hexdigest(content: str | bytes, salt: str | bytes) -> str:
Expand All @@ -77,6 +85,14 @@ def retry_fingerprint(content: Any, salt: str | bytes) -> str:
)


def user_id_hash(user_id: str, salt: str | bytes) -> str:
return hmac_sha256_hexdigest(f"agentprof:user_id:{user_id}", salt)


def session_id_hash(session_id: str, salt: str | bytes) -> str:
return hmac_sha256_hexdigest(f"agentprof:session_id:{session_id}", salt)


def normalize_volatile_text(value: str) -> str:
return _normalize_text(value, strip_volatile=True)

Expand Down
Loading
Loading