diff --git a/README.md b/README.md index 80a396c..4b7a87e 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ value is a per-run environment variable. | `CC_LANGFUSE_DEBUG` | Verbose logging to the hook log (default `false`). | No | | `CC_LANGFUSE_MAX_CHARS` | Truncate captured inputs and outputs to this many characters (default `20000`). | No | | `CC_LANGFUSE_SKILL_TAGS` | Tag traces with `skill:` for every skill invoked in the turn (default `true`). | No | +| `CC_LANGFUSE_TRACE_TAGS` | Per-run environment variable. Your own tags added to every trace, next to `claude-code` and the `skill:*` tags. Either a JSON array (`["lane:review","env:prod"]`) or a comma-separated list (`lane:review,env:prod`). Entries over 200 characters, and anything past the first 20, are dropped with a log line. Not applied when a run attaches to an existing trace, because the tags there belong to the calling application. | No | | `CC_LANGFUSE_CAPTURE_SKILL_CONTENT` | Include injected skill instruction text in the Skill tool span output (default `false`). | No | | `CC_LANGFUSE_CAPTURE_IMAGES` | Upload images to Langfuse and show them in the trace (default `true`). Needs media upload on your deployment (self-hosted: `LANGFUSE_S3_MEDIA_UPLOAD_*`). Set it to `false` if media upload is unavailable: the trace then shows a marker per image, such as `[image image/png ~200KB]`. | No | | `CC_LANGFUSE_STATE_DIR` | Absolute directory (`~` is expanded) for the hook's state, lock and log files (default `~/.claude/state`). Set one per `CLAUDE_CONFIG_DIR` installation to keep them apart. An unusable value falls back to the default and logs a warning. | No | diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index ef2558e..4883002 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -40,14 +40,66 @@ def _opt(name: str) -> str: SKILL_TAGS = (_opt("CC_LANGFUSE_SKILL_TAGS") or "true").lower() == "true" CAPTURE_SKILL_CONTENT = _opt("CC_LANGFUSE_CAPTURE_SKILL_CONTENT").lower() == "true" CAPTURE_IMAGES = (_opt("CC_LANGFUSE_CAPTURE_IMAGES") or "true").lower() == "true" +OPERATOR_TAGS_VAR = "CC_LANGFUSE_TRACE_TAGS" try: MAX_CHARS = int(_opt("CC_LANGFUSE_MAX_CHARS") or "20000") except ValueError: MAX_CHARS = 20000 +MAX_OPERATOR_TAGS = 20 +MAX_OPERATOR_TAG_CHARS = 200 + +# Bound for unresolved task notifications kept in the state file between runs. MAX_PENDING_TASK_NOTIFICATIONS = 50 INTERRUPTED_TURN_MARKER = "[Request interrupted by user]" +def parse_operator_tags(raw: str) -> Tuple[List[str], str]: + """Parse caller tags from a JSON array or a comma-separated list. + + Returns the tags and a warning naming what was dropped. Bad input yields no + tags and a warning, never an exception: telemetry stays best-effort. + """ + raw = (raw or "").strip() + if not raw: + return [], "" + notes: List[str] = [] + values: List[Any] + if raw.startswith("{"): + return [], f"{OPERATOR_TAGS_VAR}: ignored, JSON is not an array" + if raw.startswith("["): + try: + parsed = json.loads(raw) + except ValueError: + return [], f"{OPERATOR_TAGS_VAR}: ignored, not valid JSON" + if not isinstance(parsed, list): + return [], f"{OPERATOR_TAGS_VAR}: ignored, JSON is not an array" + values = parsed + else: + values = raw.split(",") + + tags: List[str] = [] + for value in values: + if not isinstance(value, (str, int, float, bool)): + notes.append("non-scalar entries") + continue + tag = str(value).strip() + if not tag: + continue + if len(tag) > MAX_OPERATOR_TAG_CHARS: + notes.append(f"entries over {MAX_OPERATOR_TAG_CHARS} characters") + continue + if tag in tags: + continue + tags.append(tag) + + if len(tags) > MAX_OPERATOR_TAGS: + notes.append(f"everything past the first {MAX_OPERATOR_TAGS}") + tags = tags[:MAX_OPERATOR_TAGS] + warning = f"{OPERATOR_TAGS_VAR}: dropped {', '.join(dict.fromkeys(notes))}" if notes else "" + return tags, warning + +OPERATOR_TAGS, _OPERATOR_TAGS_WARNING = parse_operator_tags(_opt(OPERATOR_TAGS_VAR)) + # ----------------- Paths ----------------- def _resolve_state_dir() -> Tuple[Path, str]: @@ -1966,6 +2018,7 @@ def get_trace_tags( if SKILL_TAGS: tags += collect_skill_tags(turn) tags += collect_subagent_skill_tags(turn, subagent_transcripts_by_tool_use_id) + tags += [tag for tag in OPERATOR_TAGS if tag not in tags] return tags # ---- Generation payloads ---- @@ -3432,6 +3485,8 @@ def main() -> int: if _STATE_DIR_WARNING: info(_STATE_DIR_WARNING) + if _OPERATOR_TAGS_WARNING: + info(_OPERATOR_TAGS_WARNING) config = get_langfuse_config() if config is None: diff --git a/tests/unit/test_operator_tags.py b/tests/unit/test_operator_tags.py new file mode 100644 index 0000000..3366d2e --- /dev/null +++ b/tests/unit/test_operator_tags.py @@ -0,0 +1,147 @@ +"""Pins the caller-supplied trace tag contract: both input shapes are accepted, +each tag is bounded, and bad input is ignored with a diagnostic.""" + +from typing import Any + + +def tags(hook_module: Any, raw: str) -> list: + return hook_module.parse_operator_tags(raw)[0] + + +def warning(hook_module: Any, raw: str) -> str: + return hook_module.parse_operator_tags(raw)[1] + + +def a_turn(hook_module: Any, read_fixture_jsonl: Any, fixture_transcript_path: Any) -> Any: + return hook_module.build_turns(read_fixture_jsonl(fixture_transcript_path("simple_turn")))[0] + + +# ----------------- both input shapes ----------------- + +def test_a_comma_separated_list_becomes_tags(hook_module): + assert tags(hook_module, "env:prod,team:platform") == ["env:prod", "team:platform"] + + +def test_a_json_array_becomes_tags(hook_module): + assert tags(hook_module, '["env:prod", "lane:review"]') == ["env:prod", "lane:review"] + + +def test_json_array_entries_are_coerced_to_strings(hook_module): + assert tags(hook_module, "[1, true]") == ["1", "True"] + + +def test_tags_keep_their_input_order(hook_module): + assert tags(hook_module, "c,a,b") == ["c", "a", "b"] + + +def test_blank_and_duplicate_tags_are_dropped(hook_module): + assert tags(hook_module, "a, ,a, b ") == ["a", "b"] + + +def test_a_tag_may_hold_spaces_and_colons(hook_module): + assert tags(hook_module, "lane:review, two words") == ["lane:review", "two words"] + + +def test_no_tags_and_no_warning_for_empty_input(hook_module): + assert hook_module.parse_operator_tags("") == ([], "") + assert hook_module.parse_operator_tags(" ") == ([], "") + + +# ----------------- bad input is ignored, not fatal ----------------- + +def test_a_broken_json_array_is_ignored_with_a_diagnostic(hook_module): + assert tags(hook_module, '["unclosed"') == [] + assert "not valid JSON" in warning(hook_module, '["unclosed"') + + +def test_a_json_object_is_ignored_with_a_diagnostic(hook_module): + # Without the guard the object text becomes one tag, because it holds no + # comma. The likely cause is a value meant for a different variable. + raw = '{"lane": "review"}' + + assert tags(hook_module, raw) == [] + assert "not an array" in warning(hook_module, raw) + + +def test_an_over_long_tag_is_dropped_with_a_diagnostic(hook_module): + raw = "keep," + "x" * (hook_module.MAX_OPERATOR_TAG_CHARS + 1) + + assert tags(hook_module, raw) == ["keep"] + assert str(hook_module.MAX_OPERATOR_TAG_CHARS) in warning(hook_module, raw) + + +def test_tags_past_the_limit_are_dropped_with_a_diagnostic(hook_module): + raw = ",".join(f"t{i}" for i in range(hook_module.MAX_OPERATOR_TAGS + 5)) + + assert len(tags(hook_module, raw)) == hook_module.MAX_OPERATOR_TAGS + assert "past the first" in warning(hook_module, raw) + + +def test_a_non_scalar_json_entry_is_dropped_with_a_diagnostic(hook_module): + raw = '["keep", {"nested": 1}]' + + assert tags(hook_module, raw) == ["keep"] + assert "non-scalar" in warning(hook_module, raw) + + +def test_one_diagnostic_names_a_reason_once(hook_module): + raw = ",".join(["x" * 300] * 3) + + assert warning(hook_module, raw).count("characters") == 1 + + +# ----------------- reaching the trace ----------------- + +def test_caller_tags_join_the_hook_tags( + hook_module, monkeypatch, read_fixture_jsonl, fixture_transcript_path +): + monkeypatch.setattr(hook_module, "OPERATOR_TAGS", ["env:prod", "lane:review"]) + turn = a_turn(hook_module, read_fixture_jsonl, fixture_transcript_path) + + assert hook_module.get_trace_tags(turn) == ["claude-code", "env:prod", "lane:review"] + + +def test_caller_tags_survive_with_skill_tags_turned_off( + hook_module, monkeypatch, read_fixture_jsonl, fixture_transcript_path +): + # The skill gate must not take the operator's own labels with it. + monkeypatch.setattr(hook_module, "SKILL_TAGS", False) + monkeypatch.setattr(hook_module, "OPERATOR_TAGS", ["lane:executor"]) + turn = a_turn(hook_module, read_fixture_jsonl, fixture_transcript_path) + + assert hook_module.get_trace_tags(turn) == ["claude-code", "lane:executor"] + + +def test_a_caller_tag_never_duplicates_a_hook_tag( + hook_module, monkeypatch, read_fixture_jsonl, fixture_transcript_path +): + monkeypatch.setattr(hook_module, "OPERATOR_TAGS", ["claude-code", "lane:background"]) + turn = a_turn(hook_module, read_fixture_jsonl, fixture_transcript_path) + + assert hook_module.get_trace_tags(turn) == ["claude-code", "lane:background"] + + +def test_no_caller_tags_leaves_the_hook_tags_untouched( + hook_module, monkeypatch, read_fixture_jsonl, fixture_transcript_path +): + monkeypatch.setattr(hook_module, "OPERATOR_TAGS", []) + turn = a_turn(hook_module, read_fixture_jsonl, fixture_transcript_path) + + assert hook_module.get_trace_tags(turn) == ["claude-code"] + + +# ----------------- config precedence ----------------- + +def test_the_env_var_wins_over_the_wizard_option(hook_module, monkeypatch): + monkeypatch.setenv("CC_LANGFUSE_TRACE_TAGS", "env:repo") + monkeypatch.setenv("CLAUDE_PLUGIN_OPTION_CC_LANGFUSE_TRACE_TAGS", "env:machine") + + assert hook_module._opt(hook_module.OPERATOR_TAGS_VAR) == "env:repo" + + +def test_a_bare_langfuse_trace_tags_variable_is_not_read(hook_module, monkeypatch): + monkeypatch.delenv("CC_LANGFUSE_TRACE_TAGS", raising=False) + monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_CC_LANGFUSE_TRACE_TAGS", raising=False) + monkeypatch.setenv("LANGFUSE_TRACE_TAGS", "should-be-ignored") + + assert hook_module._opt(hook_module.OPERATOR_TAGS_VAR) == ""