Skip to content
Closed
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ The plugin requires or accepts:
| `CC_LANGFUSE_TRACE_SEED` | Optional. Opt-in seed for deterministic trace IDs — see [Deterministic trace IDs](#deterministic-trace-ids). |
| `CC_LANGFUSE_TRACEPARENT` | Optional, per run (env var, not plugin config). W3C traceparent of an existing trace to attach to — see [Attach runs to an existing trace](#attach-runs-to-an-existing-trace). |
| `CC_LANGFUSE_PARENT_TRACE_ID` / `CC_LANGFUSE_PARENT_SPAN_ID` | Optional, per run. Explicit alternative to `CC_LANGFUSE_TRACEPARENT` (32-hex trace id + 16-hex span id). |
| `CC_LANGFUSE_TAGS` | Optional, per run (env var, not plugin config). Comma-separated tags added to every trace, e.g. `env:prod,team:platform`. Blank entries and duplicates are ignored; tags over 200 characters, and anything past the first 20, are dropped with a log line. Not applied in attached mode — see [Attach runs to an existing trace](#attach-runs-to-an-existing-trace). |

Get keys from your Langfuse project settings → API Keys.

Expand Down
49 changes: 47 additions & 2 deletions hooks/langfuse_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,45 @@ def _opt(name: str) -> str:
"""
return os.environ.get(name) or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name}") or ""

# Bounds for operator-supplied tags. 200 mirrors the SDK's own per-tag limit, so a
# tag this hook accepts is never dropped again downstream, and one it rejects is
# reported in this hook's log instead of vanishing into the SDK's logger.
MAX_CUSTOM_TAGS = 20
MAX_CUSTOM_TAG_CHARS = 200

def parse_custom_tags(raw: str) -> Tuple[List[str], str]:
"""Parse CC_LANGFUSE_TAGS into a bounded, de-duplicated tag list.

Comma-separated; tokens are stripped and empty ones dropped, so "a, b,,c,"
yields ["a", "b", "c"]. Order is preserved and repeats collapse onto their
first occurrence. Interior whitespace is kept, so "phase:review pass 2"
stays a single tag.

Returns (tags, warning). The warning is returned rather than logged because
this runs at import time, before the logger exists; main() emits it next to
the state-dir warning, the same shape _resolve_state_dir already uses.
"""
tags: List[str] = []
too_long = 0
for token in raw.split(","):
tag = token.strip()
if not tag or tag in tags:
continue
if len(tag) > MAX_CUSTOM_TAG_CHARS:
too_long += 1
continue
tags.append(tag)
notes: List[str] = []
if too_long:
notes.append(f"{too_long} over {MAX_CUSTOM_TAG_CHARS} chars")
if len(tags) > MAX_CUSTOM_TAGS:
notes.append(f"{len(tags) - MAX_CUSTOM_TAGS} past the {MAX_CUSTOM_TAGS}-tag limit")
tags = tags[:MAX_CUSTOM_TAGS]
return tags, (f"CC_LANGFUSE_TAGS: dropped {', '.join(notes)}" if notes else "")

DEBUG = _opt("CC_LANGFUSE_DEBUG").lower() == "true"
SKILL_TAGS = (_opt("CC_LANGFUSE_SKILL_TAGS") or "true").lower() == "true"
CUSTOM_TAGS, _CUSTOM_TAGS_WARNING = parse_custom_tags(_opt("CC_LANGFUSE_TAGS"))
CAPTURE_SKILL_CONTENT = _opt("CC_LANGFUSE_CAPTURE_SKILL_CONTENT").lower() == "true"
CAPTURE_IMAGES = (_opt("CC_LANGFUSE_CAPTURE_IMAGES") or "true").lower() == "true"
try:
Expand Down Expand Up @@ -1754,11 +1791,15 @@ def get_trace_tags(
turn: Turn,
subagent_transcripts_by_tool_use_id: Optional[Dict[str, Dict[str, Any]]] = None,
) -> List[str]:
tags = ["claude-code"]
# Operator tags come before the data-derived ones so every trace of a run shares
# a stable prefix, and they sit outside the SKILL_TAGS gate: turning skill tags
# off should not discard labels the launching process asked for.
tags = ["claude-code"] + CUSTOM_TAGS
if SKILL_TAGS:
tags += collect_skill_tags(turn)
tags += collect_subagent_skill_tags(turn, subagent_transcripts_by_tool_use_id)
return tags
# A custom tag may repeat "claude-code" or a skill tag; keep its first position.
return list(dict.fromkeys(tags))

# ---- Generation payloads ----
def build_generation_input(
Expand Down Expand Up @@ -2749,6 +2790,8 @@ def emit_turn(langfuse: Langfuse, session_id: str, turn_num: int,
# Attached mode: trace name, session, user and tags are trace-level
# fields owned by the launching application's trace — propagating
# them would overwrite the application's own values server-side.
# This drops CC_LANGFUSE_TAGS too: a caller able to set a parent trace
# context is already a Langfuse client and can tag its own trace.
attribute_propagation = contextlib.nullcontext()
with attribute_propagation:
trace_span = None
Expand Down Expand Up @@ -3017,6 +3060,8 @@ def main() -> int:

if _STATE_DIR_WARNING:
info(_STATE_DIR_WARNING)
if _CUSTOM_TAGS_WARNING:
info(_CUSTOM_TAGS_WARNING)

config = get_langfuse_config()
if config is None:
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/test_config_precedence.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ def clean_config_env(monkeypatch: pytest.MonkeyPatch) -> None:
f"CLAUDE_PLUGIN_OPTION_CC_{name}",
):
monkeypatch.delenv(key, raising=False)
for key in (
"LANGFUSE_TAGS",
"CC_LANGFUSE_TAGS",
"CLAUDE_PLUGIN_OPTION_CC_LANGFUSE_TAGS",
):
monkeypatch.delenv(key, raising=False)


def _read_log(hook_module: Any) -> str:
Expand Down Expand Up @@ -184,3 +190,28 @@ def test_env_keys_with_code_default_host_do_not_warn(hook_module: Any, monkeypat
assert config.host == "https://cloud.langfuse.com"
assert config.user_id is None
assert "mixed-source" not in _read_log(hook_module)


# ----------------- CC_LANGFUSE_TAGS: _opt, not _core_opt -----------------

def test_custom_tags_env_var_wins_over_wizard_option(hook_module: Any, monkeypatch):
monkeypatch.setenv("CC_LANGFUSE_TAGS", "env:repo")
monkeypatch.setenv("CLAUDE_PLUGIN_OPTION_CC_LANGFUSE_TAGS", "env:machine")

assert hook_module._opt("CC_LANGFUSE_TAGS") == "env:repo"


def test_custom_tags_fall_through_to_wizard_when_env_is_empty(hook_module: Any, monkeypatch):
monkeypatch.setenv("CC_LANGFUSE_TAGS", "")
monkeypatch.setenv("CLAUDE_PLUGIN_OPTION_CC_LANGFUSE_TAGS", "env:machine")

assert hook_module._opt("CC_LANGFUSE_TAGS") == "env:machine"


def test_bare_langfuse_tags_env_var_is_not_read(hook_module: Any, monkeypatch):
"""CC_LANGFUSE_TAGS resolves through _opt, not _core_opt, so the un-prefixed
LANGFUSE_TAGS is deliberately not claimed — the Langfuse SDK does not define
it, and taking the name would be a namespace land-grab."""
monkeypatch.setenv("LANGFUSE_TAGS", "env:bare")

assert hook_module._opt("CC_LANGFUSE_TAGS") == ""
102 changes: 102 additions & 0 deletions tests/unit/test_custom_tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from __future__ import annotations

import pytest


def parse(hook_module, raw: str):
return hook_module.parse_custom_tags(raw)


def tags(hook_module, raw: str) -> list[str]:
return parse(hook_module, raw)[0]


def warning(hook_module, raw: str) -> str:
return parse(hook_module, raw)[1]


def test_empty_input_yields_no_tags(hook_module):
assert parse(hook_module, "") == ([], "")


def test_single_tag(hook_module):
assert tags(hook_module, "env:prod") == ["env:prod"]


def test_multiple_tags_keep_input_order(hook_module):
assert tags(hook_module, "env:prod,team:platform") == ["env:prod", "team:platform"]


def test_surrounding_whitespace_is_stripped(hook_module):
assert tags(hook_module, " env:prod , team:platform ") == [
"env:prod",
"team:platform",
]


@pytest.mark.parametrize(
"raw,expected",
[
("a,,b,", ["a", "b"]),
(",", []),
(" ", []),
(",,,", []),
],
)
def test_empty_tokens_are_dropped(hook_module, raw, expected):
assert tags(hook_module, raw) == expected


def test_duplicates_collapse_onto_first_occurrence(hook_module):
assert tags(hook_module, "b,a,b") == ["b", "a"]


def test_interior_whitespace_is_preserved(hook_module):
"""A tag is only split on commas, so a space inside one is not a separator."""
assert tags(hook_module, "phase:review pass 2") == ["phase:review pass 2"]


def test_value_without_a_colon_is_a_valid_tag(hook_module):
assert tags(hook_module, "nightly") == ["nightly"]


def test_tag_count_is_capped_and_warned(hook_module):
raw = ",".join(f"tag{i}" for i in range(25))
parsed, note = parse(hook_module, raw)

assert len(parsed) == hook_module.MAX_CUSTOM_TAGS
assert parsed[0] == "tag0"
assert parsed[-1] == f"tag{hook_module.MAX_CUSTOM_TAGS - 1}"
assert "CC_LANGFUSE_TAGS" in note
assert "5" in note


def test_overlong_tag_is_dropped_not_truncated(hook_module):
"""A truncated tag is a *wrong* tag, and Langfuse tag facets are project-global."""
overlong = "x" * (hook_module.MAX_CUSTOM_TAG_CHARS + 1)
parsed, note = parse(hook_module, f"{overlong},env:prod")

assert parsed == ["env:prod"]
assert str(hook_module.MAX_CUSTOM_TAG_CHARS) in note


def test_tag_at_exactly_the_length_limit_is_kept(hook_module):
exact = "x" * hook_module.MAX_CUSTOM_TAG_CHARS
assert parse(hook_module, exact) == ([exact], "")


def test_no_warning_when_nothing_was_dropped(hook_module):
assert warning(hook_module, "env:prod,team:platform") == ""


def test_pathological_input_returns_without_raising(hook_module):
parsed, note = parse(hook_module, "x" * 100_000)

assert parsed == []
assert str(hook_module.MAX_CUSTOM_TAG_CHARS) in note


def test_many_pathological_tokens_stay_bounded(hook_module):
parsed, _ = parse(hook_module, ",".join(f"t{i}" for i in range(10_000)))

assert len(parsed) == hook_module.MAX_CUSTOM_TAGS
90 changes: 90 additions & 0 deletions tests/unit/test_skill_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

from typing import Any

import pytest


@pytest.fixture(autouse=True)
def _no_ambient_custom_tags(hook_module, monkeypatch):
"""hook_module is session-scoped, so CUSTOM_TAGS froze at import from the
developer's own environment. Reset it per test; cases that want custom tags
set them explicitly with monkeypatch.setattr (setenv would have no effect)."""
monkeypatch.setattr(hook_module, "CUSTOM_TAGS", [])


def make_user_row(text: str) -> dict[str, Any]:
return {
Expand Down Expand Up @@ -172,3 +182,83 @@ def test_same_skill_across_two_subagents_is_tagged_once(hook_module, tmp_path):
assert hook_module.collect_subagent_skill_tags(turns[0], sub_map) == [
"subagent-skill:deep-research",
]


# --- Operator tags from CC_LANGFUSE_TAGS -------------------------------------
# hook_module is session-scoped, so CUSTOM_TAGS cannot be driven with setenv;
# these set the parsed value directly, which is what the hook reads.


def make_plain_assistant_row() -> dict[str, Any]:
"""An assistant turn that invokes no skill, so only custom tags can appear."""
return {
"type": "assistant",
"timestamp": "2026-01-01T00:00:03.000Z",
"uuid": "assistant-plain",
"message": {
"id": "msg-plain",
"role": "assistant",
"model": "claude-test",
"content": [{"type": "text", "text": "No skill was used here."}],
},
}


def trace_tags(hook_module, rows: list[dict[str, Any]]) -> list[str]:
turns = hook_module.build_turns(rows)
assert len(turns) == 1
return hook_module.get_trace_tags(turns[0])


def test_custom_tags_follow_the_base_tag(hook_module, monkeypatch):
monkeypatch.setattr(hook_module, "CUSTOM_TAGS", ["env:prod"])

assert trace_tags(hook_module, [
make_user_row("Hello."),
make_plain_assistant_row(),
]) == ["claude-code", "env:prod"]


def test_custom_tags_precede_skill_tags(hook_module, monkeypatch):
monkeypatch.setattr(hook_module, "CUSTOM_TAGS", ["env:prod"])

assert trace_tags(hook_module, [
make_user_row("Use a skill."),
make_skill_tool_use_row("claude-api"),
]) == ["claude-code", "env:prod", "skill:claude-api"]


def test_no_custom_tags_leaves_trace_tags_unchanged(hook_module):
assert trace_tags(hook_module, [
make_user_row("Hello."),
make_plain_assistant_row(),
]) == ["claude-code"]


def test_custom_tags_apply_when_skill_tags_are_disabled(hook_module, monkeypatch):
"""CC_LANGFUSE_SKILL_TAGS=false must not discard operator-supplied labels."""
monkeypatch.setattr(hook_module, "CUSTOM_TAGS", ["env:prod"])
monkeypatch.setattr(hook_module, "SKILL_TAGS", False)

assert trace_tags(hook_module, [
make_user_row("Use a skill."),
make_skill_tool_use_row("claude-api"),
]) == ["claude-code", "env:prod"]


def test_custom_tag_duplicating_a_skill_tag_appears_once(hook_module, monkeypatch):
monkeypatch.setattr(hook_module, "CUSTOM_TAGS", ["skill:claude-api"])

assert trace_tags(hook_module, [
make_user_row("Use a skill."),
make_skill_tool_use_row("claude-api"),
]) == ["claude-code", "skill:claude-api"]


def test_custom_tag_duplicating_the_base_tag_appears_once(hook_module, monkeypatch):
monkeypatch.setattr(hook_module, "CUSTOM_TAGS", ["claude-code"])

assert trace_tags(hook_module, [
make_user_row("Hello."),
make_plain_assistant_row(),
]) == ["claude-code"]