feat: track SDK/MCP client usage in Logfire spans (#380)

- add request_attributes_mapper to parse User-Agent headers
- enrich spans with client_type (sdk/mcp/browser) and client_version
- enables filtering/clustering traffic by source in Logfire UI
- fix stats positioning on album detail page (move Header outside container)
- fix pre-existing lint warning in test_auth.py

closes #377

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub 79f39009 d901f3dd

Changed files
+112 -4
backend
frontend
src
routes
u
[handle]
album
[slug]
+39 -2
backend/src/backend/main.py
··· 1 """relay fastapi application.""" 2 3 import logging 4 import warnings 5 from collections.abc import AsyncIterator 6 from contextlib import asynccontextmanager 7 8 - from fastapi import FastAPI, Request 9 from fastapi.middleware.cors import CORSMiddleware 10 from fastapi.responses import ORJSONResponse 11 from slowapi import _rate_limit_exceeded_handler ··· 71 72 logger = logging.getLogger(__name__) 73 74 75 class SecurityHeadersMiddleware(BaseHTTPMiddleware): 76 """middleware to add security headers to all responses.""" ··· 134 135 # instrument fastapi with logfire 136 if logfire: 137 - logfire.instrument_fastapi(app) 138 139 # add security headers middleware 140 app.add_middleware(SecurityHeadersMiddleware)
··· 1 """relay fastapi application.""" 2 3 import logging 4 + import re 5 import warnings 6 from collections.abc import AsyncIterator 7 from contextlib import asynccontextmanager 8 + from typing import Any 9 10 + from fastapi import FastAPI, Request, WebSocket 11 from fastapi.middleware.cors import CORSMiddleware 12 from fastapi.responses import ORJSONResponse 13 from slowapi import _rate_limit_exceeded_handler ··· 73 74 logger = logging.getLogger(__name__) 75 76 + # pattern to match plyrfm SDK/MCP user-agent headers 77 + # format: "plyrfm/{version}" or "plyrfm-mcp/{version}" 78 + _PLYRFM_UA_PATTERN = re.compile(r"^plyrfm(-mcp)?/(\d+\.\d+\.\d+)") 79 + 80 + 81 + def parse_plyrfm_user_agent(user_agent: str | None) -> dict[str, str]: 82 + """parse plyrfm SDK/MCP user-agent into span attributes. 83 + 84 + returns dict with: 85 + - client_type: "sdk", "mcp", or "browser" 86 + - client_version: version string (only for sdk/mcp) 87 + """ 88 + if not user_agent: 89 + return {"client_type": "browser"} 90 + 91 + match = _PLYRFM_UA_PATTERN.match(user_agent) 92 + if not match: 93 + return {"client_type": "browser"} 94 + 95 + is_mcp = match.group(1) is not None # "-mcp" suffix present 96 + version = match.group(2) 97 + 98 + return { 99 + "client_type": "mcp" if is_mcp else "sdk", 100 + "client_version": version, 101 + } 102 + 103 + 104 + def request_attributes_mapper( 105 + request: Request | WebSocket, attributes: dict[str, Any], / 106 + ) -> dict[str, Any] | None: 107 + """extract client metadata from request headers for span enrichment.""" 108 + user_agent = request.headers.get("user-agent") 109 + return parse_plyrfm_user_agent(user_agent) 110 + 111 112 class SecurityHeadersMiddleware(BaseHTTPMiddleware): 113 """middleware to add security headers to all responses.""" ··· 171 172 # instrument fastapi with logfire 173 if logfire: 174 + logfire.instrument_fastapi(app, request_attributes_mapper=request_attributes_mapper) 175 176 # add security headers middleware 177 app.add_middleware(SecurityHeadersMiddleware)
+1 -1
backend/tests/test_auth.py
··· 198 # consume token first time 199 result = await consume_exchange_token(token) 200 assert result is not None 201 - returned_session_id, is_dev_token = result 202 assert returned_session_id == session_id 203 204 # try to consume again - should return None
··· 198 # consume token first time 199 result = await consume_exchange_token(token) 200 assert result is not None 201 + returned_session_id, _is_dev_token = result 202 assert returned_session_id == session_id 203 204 # try to consume again - should return None
+71
backend/tests/test_user_agent.py
···
··· 1 + """tests for user-agent parsing and span enrichment.""" 2 + 3 + import pytest 4 + 5 + from backend.main import parse_plyrfm_user_agent 6 + 7 + 8 + class TestParsePlyrfmUserAgent: 9 + """tests for parse_plyrfm_user_agent function.""" 10 + 11 + def test_sdk_user_agent(self) -> None: 12 + """sdk user-agent returns client_type=sdk with version.""" 13 + result = parse_plyrfm_user_agent("plyrfm/0.1.0") 14 + assert result == {"client_type": "sdk", "client_version": "0.1.0"} 15 + 16 + def test_mcp_user_agent(self) -> None: 17 + """mcp user-agent returns client_type=mcp with version.""" 18 + result = parse_plyrfm_user_agent("plyrfm-mcp/0.2.1") 19 + assert result == {"client_type": "mcp", "client_version": "0.2.1"} 20 + 21 + def test_browser_user_agent(self) -> None: 22 + """standard browser user-agent returns client_type=browser.""" 23 + result = parse_plyrfm_user_agent( 24 + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " 25 + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" 26 + ) 27 + assert result == {"client_type": "browser"} 28 + assert "client_version" not in result 29 + 30 + def test_none_user_agent(self) -> None: 31 + """missing user-agent returns client_type=browser.""" 32 + result = parse_plyrfm_user_agent(None) 33 + assert result == {"client_type": "browser"} 34 + 35 + def test_empty_user_agent(self) -> None: 36 + """empty string user-agent returns client_type=browser.""" 37 + result = parse_plyrfm_user_agent("") 38 + assert result == {"client_type": "browser"} 39 + 40 + def test_curl_user_agent(self) -> None: 41 + """curl user-agent returns client_type=browser (generic fallback).""" 42 + result = parse_plyrfm_user_agent("curl/8.4.0") 43 + assert result == {"client_type": "browser"} 44 + 45 + @pytest.mark.parametrize( 46 + ("user_agent", "expected_type", "expected_version"), 47 + [ 48 + ("plyrfm/1.0.0", "sdk", "1.0.0"), 49 + ("plyrfm/0.0.1", "sdk", "0.0.1"), 50 + ("plyrfm/10.20.30", "sdk", "10.20.30"), 51 + ("plyrfm-mcp/1.0.0", "mcp", "1.0.0"), 52 + ("plyrfm-mcp/0.0.1", "mcp", "0.0.1"), 53 + ], 54 + ) 55 + def test_version_variations( 56 + self, user_agent: str, expected_type: str, expected_version: str 57 + ) -> None: 58 + """various version formats are parsed correctly.""" 59 + result = parse_plyrfm_user_agent(user_agent) 60 + assert result["client_type"] == expected_type 61 + assert result["client_version"] == expected_version 62 + 63 + def test_plyrfm_prefix_not_at_start(self) -> None: 64 + """plyrfm in middle of string is not matched (browser fallback).""" 65 + result = parse_plyrfm_user_agent("Mozilla/5.0 plyrfm/1.0.0") 66 + assert result == {"client_type": "browser"} 67 + 68 + def test_invalid_version_format(self) -> None: 69 + """invalid version format falls back to browser.""" 70 + result = parse_plyrfm_user_agent("plyrfm/v1.0") 71 + assert result == {"client_type": "browser"}
+1 -1
frontend/src/routes/u/[handle]/album/[slug]/+page.svelte
··· 71 {/if} 72 </svelte:head> 73 74 <div class="container"> 75 - <Header user={auth.user} isAuthenticated={auth.isAuthenticated} onLogout={() => goto('/login')} /> 76 <main> 77 <div class="album-hero"> 78 {#if album.metadata.image_url}
··· 71 {/if} 72 </svelte:head> 73 74 + <Header user={auth.user} isAuthenticated={auth.isAuthenticated} onLogout={() => goto('/login')} /> 75 <div class="container"> 76 <main> 77 <div class="album-hero"> 78 {#if album.metadata.image_url}