diff --git a/README.md b/README.md index f5b969e..1d14670 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index cbabf35..0290dee 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -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: @@ -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( @@ -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 @@ -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: diff --git a/tests/unit/test_config_precedence.py b/tests/unit/test_config_precedence.py index c37ac58..5daef87 100644 --- a/tests/unit/test_config_precedence.py +++ b/tests/unit/test_config_precedence.py @@ -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: @@ -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") == "" diff --git a/tests/unit/test_custom_tags.py b/tests/unit/test_custom_tags.py new file mode 100644 index 0000000..cdb66b5 --- /dev/null +++ b/tests/unit/test_custom_tags.py @@ -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 diff --git a/tests/unit/test_skill_tags.py b/tests/unit/test_skill_tags.py index ffe5fd4..ae52958 100644 --- a/tests/unit/test_skill_tags.py +++ b/tests/unit/test_skill_tags.py @@ -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 { @@ -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"]