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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>` 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 |
Expand Down
55 changes: 55 additions & 0 deletions hooks/langfuse_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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 ----
Expand Down Expand Up @@ -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:
Expand Down
147 changes: 147 additions & 0 deletions tests/unit/test_operator_tags.py
Original file line number Diff line number Diff line change
@@ -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) == ""