Skip to content

feat(plugins): add the chat analytics event vocabulary - #574

Open
zaira-bibi wants to merge 3 commits into
mainfrom
zaira/feat/chat-analytics-vocabulary
Open

feat(plugins): add the chat analytics event vocabulary#574
zaira-bibi wants to merge 3 commits into
mainfrom
zaira/feat/chat-analytics-vocabulary

Conversation

@zaira-bibi

@zaira-bibi zaira-bibi commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Stack 2 of 4 — Analytics Milestone 2. Based on
#572, which adds the emit_event primitive
this PR's helpers call. Review #572 first.

What

Adds the chat plugin's analytics vocabulary — four chat.* event schemas, a tool-name normaliser, a tool-category lookup, and four emit helpers — with no route wired up yet, so the whole thing is additive and independently reviewable.

Changes

  • feat(plugins): add four AnalyticsEventSchema subclasses (chat.conversation_started, chat.message_sent, chat.completion_served, chat.tool_invoked) plus the frozen CompletionAnalyticsContext, registered from ChatPlugin.__init__
  • feat(plugins): add tool_names() — one normaliser reading tool executions from both completion paths' differently-shaped records
  • feat(plugins): record MCP tool categories during registry discovery and expose ToolRegistry.category_for(), defaulting to UNKNOWN_TOOL_CATEGORY
  • feat(plugins): add four emit_* helpers that build each payload and hand it to emit_event

How to Test

  1. Schemas, normaliser, and helpers:
    uv run pytest sparkth/plugins/chat/tests/test_analytics_schemas.py -v
    Expected: 27 passed.
  2. Registry category lookup:
    uv run pytest sparkth/plugins/chat/tests/test_tool_registry.py -v
  3. No regressions in the plugin, and no DuplicateEventTypeError from the new registration:
    uv run pytest sparkth/plugins/chat/ -q
    Expected: 292 passed.
  4. Confirm no import cycle from analytics.py now importing tools.py:
    uv run python -c "import sparkth.main"
    Expected: no output, exit 0.

Notes

  • No migration. New event types are rows in the existing raw_events table, not DDL.
  • Payload safety is enforced structurally. Every payload carries identifiers, lengths, flags and names only. The base schema's extra="forbid" rejects anything else, and tool_names() reads only the name key — never tool_input or output, which can hold course content and learner-identifying data.
  • tool_names() exists because the two completion paths disagree on shape: the streaming path's tool_end events yield {"name": …}, while the non-streaming provider records {"tool", "tool_input", "output"}. Keeping that mapping in one place is what stops the two seams in stack 3/4 from drifting apart.
  • Nothing here catches exceptions, matching emit_event's contract from feat(analytics): add emit_event for analytics producers #572 — a failed analytics write propagates rather than being hidden.
  • No route is wired yet. Nothing in this PR emits during a real request; that starts in stack 3.
  • No new env vars, no dependency changes.

This PR description was written with the assistance of an LLM (Claude).

@zaira-bibi zaira-bibi changed the title zaira/feat/chat analytics vocabulary feat(plugins): add the chat analytics event vocabulary Aug 4, 2026
@zaira-bibi zaira-bibi self-assigned this Aug 4, 2026
@zaira-bibi

Copy link
Copy Markdown
Contributor Author

@claude-review

@claude

claude Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @zaira-bibi's task in 1m 59s —— View job


Claude PR Review — feat(plugins): add the chat analytics event vocabulary

  • Read changed files (analytics.py, plugin.py, tools.py, tests)
  • Review schemas, normaliser, category lookup, emit helpers
  • Check payload-safety / PII, exception handling, import cycles
  • Validate test coverage
  • Post review feedback

Reviewed against origin/analytics-primitive. This is a clean, well-scoped, purely additive slice — nothing is wired into a request path yet, matching the stated intent. Code and docstrings are of high quality and I found no blocking issues. Two low-severity test-quality notes posted inline.

What this PR does

Adds the chat plugin's analytics vocabulary: four chat.* AnalyticsEventSchema subclasses (registered from ChatPlugin.__init__), the frozen CompletionAnalyticsContext, a tool_names() normaliser over both completion paths' record shapes, ToolRegistry.category_for(), and four emit_* helpers.

Strengths 👍

  • Payload safety is structurally enforced, not just documented. Every payload carries only ids / lengths / flags / names; the base extra="forbid" (base.py:31) rejects anything else, and tool_names() reads only the name key — never tool_input/output. The test_returns_only_names_never_inputs_or_outputs test locks this contract in. This is the right instinct for an analytics seam.
  • The two-shape rationale is real and correctly centralised. _TOOL_NAME_KEYS = ("name", "tool") with first-non-empty-string wins is robust — it also correctly skips an empty "name" and falls through to "tool", and drops None/"" without raising. Both shapes are covered by tests.
  • No import cycleanalytics.pytools.py is one-directional (tools.py doesn't import analytics); category_for resolution is pushed into the helper so no call site needs to know LangChain tools drop their category.
  • Exception discipline is respected. The helpers catch nothing, matching emit_event's documented "never silently hide a broken analytics write" contract (analytics/__init__.py:56-83). discover_plugin_tools keeps its existing narrow (KeyError, TypeError, ValueError, ValidationError) catch.
  • UNKNOWN_TOOL_CATEGORY fallback means a missing category degrades one dimension instead of breaking emission — the right failure mode, and category_for for an unregistered name is tested.
  • Schemas use ClassVar identity via the base __init_subclass__ guard, so event_type/version stay out of the validated payload — verified against base.py. Registration namespacing/collision is covered by test_schema_is_registered_under_its_own_identity + test_event_type_is_namespaced_under_the_plugin.
  • Modern typing (X | None, list[dict[str, Any]]), thorough docstrings, and the "why" comments explaining streamed / actor_id / model_override are genuinely useful.

Suggestions (non-blocking)

Low — test asserts a base Exception · tests/test_analytics_schemas.py:91
with pytest.raises(Exception): context.model = ... passes on any error. Assert dataclasses.FrozenInstanceError so the test fails if the write fails for the wrong reason. Inline suggestion posted. Fix this →

Low — potentially vacuous test · tests/test_tool_registry.py:288
test_discovered_tool_reports_its_mcp_category loops over registry._categories; if discovery registers zero tools the loop never runs and the test passes without asserting anything. Add assert registry._categories before the loop. Inline suggestion posted.

Nit — minor duplication in emit helpers. Each emit_* reuses the pattern of building a dict + passing ClassName.event_type, ClassName.version. That's fine as-is (explicit and readable), but if a 5th event arrives you might consider schema.model_validate(payload) / a tiny builder to keep event_type/version co-located with the schema. Not worth changing now.

Test coverage

Solid: all four schemas parametrised for registration + namespacing + extra="forbid", per-payload shape tests, the full tool_names matrix (both shapes, order/dupes, empty, unusable names, PII exclusion), all four emit helpers asserting the exact emit_event call, and registry category lookup incl. the unknown fallback and reset. Matches the TDD expectations in CLAUDE.md.

Verdict

Approve (with two minor test-quality nits). No correctness, security, or performance concerns. The payload-minimisation design is exactly right for an analytics vocabulary.

Note: I can't submit a formal GitHub approval — for security reasons this is posted as review feedback only.
· branch zaira/feat/chat-analytics-vocabulary

Comment on lines +90 to +92
def test_schemas_forbid_extra_fields(schema: type[AnalyticsEventSchema]) -> None:
"""extra="forbid" on the base class stops a producer smuggling content into a payload."""
with pytest.raises(ValidationError):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — assert the specific exception. pytest.raises(Exception) will pass on any error, so this test would still pass if the assignment failed for an unrelated reason (e.g. a typo'd attribute name). A frozen dataclass raises dataclasses.FrozenInstanceError, so assert on that:

Suggested change
def test_schemas_forbid_extra_fields(schema: type[AnalyticsEventSchema]) -> None:
"""extra="forbid" on the base class stops a producer smuggling content into a payload."""
with pytest.raises(ValidationError):
context = CompletionAnalyticsContext(provider="openai", model="gpt-4o", rag_used=True, actor_id="7")
with pytest.raises(FrozenInstanceError):
context.model = "changed" # type: ignore[misc]

(requires from dataclasses import FrozenInstanceError at the top.)

Comment on lines +287 to +291
registry = ToolRegistry()
registry.discover_plugin_tools()
# Every discovered tool must resolve to some category string, never raise.
for name in registry._categories:
assert isinstance(registry.category_for(name), str)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — this test is vacuous if discovery finds no tools. If discover_plugin_tools() registers zero tools (no MCP tools present in the test environment), the for loop body never runs and the test passes without asserting anything. Add a guard so it fails loudly rather than silently rubber-stamping:

        registry.discover_plugin_tools()
        assert registry._categories, "expected discovery to register at least one categorised tool"
        for name in registry._categories:
            ...

Same file reaches into registry._categories (a private attr) here and in test_reset_clears_categories. That's acceptable for a whitebox registry test, but a public assertion via category_for(name) alone would be slightly more robust to internal renames.

@zaira-bibi
zaira-bibi marked this pull request as ready for review August 4, 2026 10:40
@zaira-bibi zaira-bibi added the enhancement New feature or request label Aug 4, 2026

@abdulrafey1 abdulrafey1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a few comments

event_type = "chat.tool_invoked"
version = 1

conversation_id: str

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

conversation_id comes up in every schema, could we create a parent class for this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the natural approach fails at import time:

class ChatEvent(AnalyticsEventSchema):      # no event_type/version
    conversation_id: str
# TypeError: ChatEvent must declare ['event_type', 'version'] as class attributes

as core/analytics/schemas/base.py enforces that every subclass declares its own identity. if we give it dummy event_type/version then a concrete schema that forgot to declare its own would silently inherit them and register under the wrong type, defeating that guard.

if we use a plain BaseModel and do something like:

class ConversationScoped(BaseModel):
    conversation_id: str

class ChatToolInvoked(ConversationScoped, AnalyticsEventSchema):
    event_type = "chat.tool_invoked"
    version = 1
    tool_name: str
    tool_category: str

this works but the DRY win is small (as it's one str field across four classes), and it also costs a non-obvious trick that exists purely to dodge a safety check. i'd rather skip this and keep the current version.

await emit_event(
ChatConversationStarted.event_type,
ChatConversationStarted.version,
{"conversation_id": conversation_id, "provider": provider, "model": model},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets indent this dict just like its indented below


# Category reported for a tool the registry never discovered. A missing category
# degrades one analytics dimension; it must never break emission.
UNKNOWN_TOOL_CATEGORY = "unknown"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets move this to the existing chat/constants.py

conversation_id: str
provider: str
model: str
message_length: int

@hamza-56 hamza-56 Aug 4, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

message_length and tool_call_count are plain ints, so a producer bug (e.g. -1) validates fine and skews aggregates. Worth using NonNegativeInt / Field(ge=0) here.

if isinstance(value, str) and value:
names.append(value)
break
return names

@hamza-56 hamza-56 Aug 4, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Records with no usable name are dropped silently. If either provider ever renames the key, this zeroes tool analytics with no trace. Worth a logger.warning on the miss (keys only, not values).

return names


async def emit_conversation_started(conversation_id: str, provider: str, model: str, actor_id: str) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each emit_* helper hand-lists its schema's fields in a dict, so a field added to the schema but forgotten here only fails at runtime. Building the payload from the schema instance (event.model_dump(mode="json")) would catch that at type-check time and collapse these four helpers into one.

def test_schemas_forbid_extra_fields(schema: type[AnalyticsEventSchema]) -> None:
"""extra="forbid" on the base class stops a producer smuggling content into a payload."""
with pytest.raises(ValidationError):
schema(unexpected_field="leak") # type: ignore[call-arg]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This passes for the wrong reason: every schema has required fields, so this raises ValidationError on the missing fields even without extra="forbid". It'd stay green if the smuggling guard were removed. Use a fully valid payload plus one extra key, and assert on extra_forbidden.


def test_completion_context_is_frozen() -> None:
context = CompletionAnalyticsContext(provider="openai", model="gpt-4o", rag_used=True, actor_id="7")
with pytest.raises(Exception):

@hamza-56 hamza-56 Aug 4, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pytest.raises(Exception) is too broad here, it'd pass on an unrelated bug too. A frozen dataclass raises FrozenInstanceError specifically, assert that.

@zaira-bibi
zaira-bibi force-pushed the zaira/feat/chat-analytics-vocabulary branch from e7fae13 to b37f11d Compare August 5, 2026 05:57
Base automatically changed from analytics-primitive to main August 5, 2026 06:00
zaira-bibi and others added 3 commits August 5, 2026 11:00
Chat is the course-authoring surface: instructors use it to generate
courses, and its tool registry exposes every other plugin's MCP tools.
These four events therefore measure authoring activity (conversations,
turns, completions) and authoring output (tool executions), not learner
engagement.

Payloads carry identifiers, lengths, flags and names only. No message
content, conversation title, prompt, tool argument or tool output can
enter a payload -- extra="forbid" on the base schema enforces that, and
the schemas are the single place the permitted field set is declared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two chat completion paths report tool executions in different
shapes: streaming tool_end events yield {"name": ...}, while the
non-streaming provider records {"tool", "tool_input", "output"}.
tool_names() reads both, so that mapping exists in exactly one place
instead of drifting between the two seams that will consume it.

It reads only the name. tool_input and output can hold course content
and learner-identifying data, and must never reach an analytics payload.

Categories live on the MCP Tool and are dropped by the LangChain
conversion, so ToolRegistry now records them while it converts.
Unknown names resolve to UNKNOWN_TOOL_CATEGORY rather than raising: a
missing category degrades one analytics dimension, it must never break
emission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The route seams should not have to know event names, schema versions, or
that LangChain tools carry no category. Each helper builds one payload,
reading its event type and version off the schema class so a version bump
cannot silently desync the emitted event from its registered schema, and
emit_tool_invoked resolves the tool category from the registry so no call
site needs to.

Nothing is caught here: a failed analytics write propagates, matching
emit_event's contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zaira-bibi
zaira-bibi force-pushed the zaira/feat/chat-analytics-vocabulary branch from b37f11d to cb4759a Compare August 5, 2026 06:00
it — so the mapping is recorded during discovery. Unknown names resolve to
:data:`UNKNOWN_TOOL_CATEGORY` rather than raising.
"""
if not self._initialized:

@regisb regisb Aug 6, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pattern is being repeated multiple times across the ToolRegistry class. I would recommend writing instead:

class ToolRegistry:
    """Registry for managing LangChain tools."""

    def __init__(self) -> None:
        self._tools: dict[str, BaseTool] | None = None

    def tools_dict(self) -> dict[str, BaseTool]:
        if self._tools is None:
            self._tools = self._discover_plugin_tools()
        return self._tools

Interestingly, while writing this, I realised that we could replace many instance methods by static methods: _convert_mcp_to_langchain_tool, _get_handler_type_hints, _build_args_schema_from_handler, _json_schema_to_pydantic, _resolve_ref, _get_python_type etc. could all be moved out of the ToolRegistry class and made normal functions.

Also, refresh_tools is unused anywhere, and we should thus gete rid of the reset method.

Maybe this might be worth having a look if you take on this PR?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants