From aab0c7123bfc47012eda062bd0f537c993bc8573 Mon Sep 17 00:00:00 2001 From: Mike Lim Date: Tue, 21 Jul 2026 13:58:05 -0500 Subject: [PATCH] feat(hook): add support for custom tags in trace tagging --- .claude-plugin/plugin.json | 5 ++++ README.md | 1 + hooks/langfuse_hook.py | 3 ++- tests/unit/test_skill_tags.py | 47 +++++++++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 35c551c..3b18bb9 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -61,6 +61,11 @@ "type": "string", "title": "Trace ID seed", "description": "Optional. Derive deterministic trace ids as Langfuse.create_trace_id(seed=f\"{seed}:{turn_number}\") so external systems can precompute them. Use a unique seed per session (e.g. the --session-id UUID). Leave empty for auto-generated ids." + }, + "CC_LANGFUSE_TAGS": { + "type": "string", + "title": "Custom tags", + "description": "Optional. Comma-separated custom tags appended to every trace (e.g. env:prod,team:platform)." } } } diff --git a/README.md b/README.md index 49a6416..75d922b 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ The plugin requires or accepts: | `CC_LANGFUSE_SKILL_TAGS` | Tag traces with `skill:` for every skill invoked in the turn (default true). | | `CC_LANGFUSE_CAPTURE_SKILL_CONTENT` | Include injected skill instruction text in the Skill tool span output (default false). | | `CC_LANGFUSE_TRACE_SEED` | Optional. Opt-in seed for deterministic trace IDs — see [Deterministic trace IDs](#deterministic-trace-ids). | +| `CC_LANGFUSE_TAGS` | Optional. Comma-separated custom tags appended to every trace (e.g. `env:prod,team:platform`). | Get keys from your Langfuse project settings → API Keys. diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index f4563c9..4430930 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -47,6 +47,7 @@ def _opt(name: str) -> str: DEBUG = _opt("CC_LANGFUSE_DEBUG").lower() == "true" SKILL_TAGS = (_opt("CC_LANGFUSE_SKILL_TAGS") or "true").lower() == "true" +CUSTOM_TAGS = [t.strip() for t in _opt("CC_LANGFUSE_TAGS").split(",") if t.strip()] CAPTURE_SKILL_CONTENT = _opt("CC_LANGFUSE_CAPTURE_SKILL_CONTENT").lower() == "true" try: MAX_CHARS = int(_opt("CC_LANGFUSE_MAX_CHARS") or "20000") @@ -1216,7 +1217,7 @@ def get_trace_tags( turn: Turn, subagent_transcripts_by_tool_use_id: Optional[Dict[str, Dict[str, Any]]] = None, ) -> List[str]: - tags = ["claude-code"] + 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) diff --git a/tests/unit/test_skill_tags.py b/tests/unit/test_skill_tags.py index ffe5fd4..df7a829 100644 --- a/tests/unit/test_skill_tags.py +++ b/tests/unit/test_skill_tags.py @@ -51,6 +51,21 @@ def make_attributed_assistant_row(skill: str, uuid: str = "assistant-attr") -> d } +def make_plain_assistant_row(uuid: str = "assistant-plain") -> dict[str, Any]: + """A reply with no skill involved, just to close out a turn.""" + return { + "type": "assistant", + "timestamp": "2026-01-01T00:00:01.000Z", + "uuid": uuid, + "message": { + "id": f"msg-{uuid}", + "role": "assistant", + "model": "claude-test", + "content": [{"type": "text", "text": "Hi there."}], + }, + } + + def collect_tags(hook_module, rows: list[dict[str, Any]]) -> list[str]: turns = hook_module.build_turns(rows) assert len(turns) == 1 @@ -172,3 +187,35 @@ 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", ] + + +def test_custom_tags_are_appended_to_every_trace(hook_module, monkeypatch): + monkeypatch.setattr(hook_module, "CUSTOM_TAGS", ["env:prod", "team:platform"]) + rows = [make_user_row("Hello."), make_plain_assistant_row()] + turns = hook_module.build_turns(rows) + + assert hook_module.get_trace_tags(turns[0]) == [ + "claude-code", + "env:prod", + "team:platform", + ] + + +def test_no_custom_tags_leaves_trace_tags_unchanged(hook_module, monkeypatch): + monkeypatch.setattr(hook_module, "CUSTOM_TAGS", []) + rows = [make_user_row("Hello."), make_plain_assistant_row()] + turns = hook_module.build_turns(rows) + + assert hook_module.get_trace_tags(turns[0]) == ["claude-code"] + + +def test_custom_tags_combine_with_skill_tags(hook_module, monkeypatch): + monkeypatch.setattr(hook_module, "CUSTOM_TAGS", ["env:prod"]) + rows = [make_user_row("Do a thing."), make_skill_tool_use_row("claude-api")] + turns = hook_module.build_turns(rows) + + assert hook_module.get_trace_tags(turns[0]) == [ + "claude-code", + "env:prod", + "skill:claude-api", + ]