Skip to content

Commit fdb232a

Browse files
authored
Merge pull request #6 from aws-solutions-library-samples/fix/otel-collector-issues
fix: add missing MetricsLogGroup and OTEL helper script
2 parents fc8ad17 + e8a9039 commit fdb232a

File tree

4 files changed

+357
-5
lines changed

4 files changed

+357
-5
lines changed

deployment/infrastructure/otel-collector.yaml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,16 @@ Resources:
5858
LogGroupName: /ecs/otel-collector
5959
RetentionInDays: 7
6060

61-
# Note: MetricsLogGroup is now created by the monitoring dashboard stack
61+
MetricsLogGroup:
62+
Type: AWS::Logs::LogGroup
63+
Properties:
64+
LogGroupName: /aws/claude-code/metrics
65+
RetentionInDays: 30
66+
Tags:
67+
- Key: Purpose
68+
Value: Claude Code Usage Metrics
69+
- Key: Stack
70+
Value: !Ref AWS::StackName
6271

6372
# Security Groups
6473
# Security Groups
@@ -294,7 +303,7 @@ Resources:
294303
295304
receivers:
296305
otlp:
297-
protocols:
306+
protocols:z
298307
grpc:
299308
endpoint: 0.0.0.0:4317
300309
include_metadata: true

source/claude_code_with_bedrock/cli/commands/package.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,7 @@ def _build_otel_helper(self, output_dir: Path, target_platform: str) -> Path:
419419
raise RuntimeError("Cannot build Linux OTEL helper on macOS without working Docker")
420420

421421
# Find the source file
422-
src_file = Path(__file__).parent.parent.parent.parent / "scripts" / "claude_otel_headers.py"
422+
src_file = Path(__file__).parent.parent.parent.parent / "otel_helper" / "__main__.py"
423423

424424
if not src_file.exists():
425425
raise FileNotFoundError(f"OTEL helper script not found: {src_file}")
@@ -477,7 +477,7 @@ def _build_otel_helper_linux_with_docker(self, output_dir: Path) -> Path:
477477
&& rm -rf /var/lib/apt/lists/*
478478
479479
# Copy source files
480-
COPY scripts/claude_otel_headers.py /build/
480+
COPY otel_helper /build/otel_helper/
481481
COPY pyproject.toml poetry.lock* /build/
482482
483483
# Install PyInstaller and dependencies
@@ -489,7 +489,7 @@ def _build_otel_helper_linux_with_docker(self, output_dir: Path) -> Path:
489489
--name otel-helper-linux \\
490490
--strip \\
491491
--noupx \\
492-
/build/claude_otel_headers.py
492+
/build/otel_helper/__main__.py
493493
"""
494494

495495
# Write temporary Dockerfile

source/otel_helper/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# ABOUTME: OTEL helper package for extracting user attributes from JWT tokens
2+
# ABOUTME: Provides HTTP headers for OpenTelemetry collector user attribution
3+
"""OTEL Helper Package for Claude Code telemetry user attribution."""

source/otel_helper/__main__.py

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
#!/usr/bin/env python3
2+
# ABOUTME: OTEL helper script that extracts user attributes from JWT tokens
3+
# ABOUTME: Outputs HTTP headers for OpenTelemetry collector to enable user attribution
4+
"""
5+
OTEL Headers Helper Script for Claude Code
6+
7+
This script retrieves authentication tokens from the storage method chosen by the customer
8+
(system keyring or session file) and formats them as HTTP headers for use with the OTEL collector.
9+
It extracts user information from JWT tokens and provides properly formatted headers
10+
that the OTEL collector's attributes processor converts to resource attributes.
11+
"""
12+
13+
import os
14+
import sys
15+
import json
16+
import base64
17+
import logging
18+
import argparse
19+
import hashlib
20+
from pathlib import Path
21+
22+
# Configure debug mode if requested
23+
DEBUG_MODE = os.environ.get("DEBUG_MODE", "").lower() in ("true", "1", "yes", "y")
24+
TEST_MODE = False # Will be set by command line argument
25+
26+
# Configure logging
27+
logging.basicConfig(
28+
level=logging.DEBUG if DEBUG_MODE else logging.WARNING,
29+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
30+
handlers=[logging.StreamHandler(sys.stderr)],
31+
)
32+
logger = logging.getLogger("claude-otel-headers")
33+
34+
# Constants - Match the same constants used by cognito_auth/__main__.py
35+
KEYRING_SERVICE = "claude-code-with-bedrock"
36+
KEYRING_USERNAME = "ClaudeCode-monitoring"
37+
SESSION_FILE_PATH = os.path.expanduser("~/.claude-code-session/ClaudeCode-monitoring.json")
38+
CONFIG_FILE_PATH = os.path.expanduser("~/claude-code-with-bedrock/config.json")
39+
DEFAULT_STORAGE = "keyring"
40+
41+
42+
def parse_args():
43+
"""Parse command-line arguments"""
44+
parser = argparse.ArgumentParser(description="Generate OTEL headers from authentication token")
45+
parser.add_argument("--service", default=KEYRING_SERVICE, help=f"Keyring service name (default: {KEYRING_SERVICE})")
46+
parser.add_argument(
47+
"--username", default=KEYRING_USERNAME, help=f"Keyring username/key (default: {KEYRING_USERNAME})"
48+
)
49+
parser.add_argument("--storage", help="Override storage method (keyring or session)")
50+
parser.add_argument("--test", action="store_true", help="Run in test mode with verbose output")
51+
parser.add_argument("--verbose", action="store_true", help="Show verbose output")
52+
args = parser.parse_args()
53+
54+
global TEST_MODE
55+
TEST_MODE = args.test
56+
57+
# Set debug mode if verbose is specified
58+
if args.verbose or args.test:
59+
global DEBUG_MODE
60+
DEBUG_MODE = True
61+
logger.setLevel(logging.DEBUG)
62+
63+
return args
64+
65+
66+
def get_configured_storage_method():
67+
"""Determine which storage method the customer has chosen in ccwb init"""
68+
try:
69+
if os.path.exists(CONFIG_FILE_PATH):
70+
with open(CONFIG_FILE_PATH, "r") as f:
71+
config = json.load(f)
72+
73+
# Check for credential_storage in config (how cognito_auth/__main__.py stores it)
74+
if "ClaudeCode" in config:
75+
storage = config.get("ClaudeCode", {}).get("credential_storage")
76+
if storage in ["keyring", "session"]:
77+
logger.info(f"Using storage method from config: {storage}")
78+
return storage
79+
80+
except Exception as e:
81+
logger.warning(f"Error reading config file: {e}")
82+
83+
logger.info(f"No storage method configured, using default: {DEFAULT_STORAGE}")
84+
return DEFAULT_STORAGE
85+
86+
87+
def get_token_from_keyring(service, username):
88+
"""Retrieve token from system keyring"""
89+
try:
90+
# Conditionally import keyring only if needed
91+
try:
92+
import keyring
93+
except ImportError:
94+
logger.warning("Keyring package is not installed - falling back to session file")
95+
return None
96+
97+
# First try to get token as saved by cognito_auth/__main__.py
98+
token_json = keyring.get_password(service, username)
99+
100+
if token_json:
101+
try:
102+
# Token might be stored as JSON string with additional metadata
103+
token_data = json.loads(token_json)
104+
if isinstance(token_data, dict) and "token" in token_data:
105+
logger.info(f"Found token in keyring JSON under {service}/{username}")
106+
return token_data["token"]
107+
except json.JSONDecodeError:
108+
# Not JSON, might be direct token string
109+
logger.info(f"Found direct token string in keyring under {service}/{username}")
110+
return token_json
111+
112+
logger.warning(f"No token found in keyring under {service}/{username}")
113+
except Exception as e:
114+
logger.warning(f"Error accessing keyring: {e} - falling back to session file")
115+
116+
return None
117+
118+
119+
def get_token_from_session_file():
120+
"""Retrieve token from session file"""
121+
try:
122+
session_file = Path(SESSION_FILE_PATH)
123+
if not session_file.exists():
124+
logger.warning(f"Session file not found: {SESSION_FILE_PATH}")
125+
return None
126+
127+
with open(session_file, "r") as f:
128+
data = json.load(f)
129+
token = data.get("token")
130+
if token:
131+
logger.info("Token found in session file")
132+
return token
133+
logger.warning("No token found in session file")
134+
except Exception as e:
135+
logger.error(f"Error reading session file: {e}")
136+
137+
return None
138+
139+
140+
def decode_jwt_payload(token):
141+
"""Decode the payload portion of a JWT token"""
142+
try:
143+
# Get the payload part (second segment)
144+
_, payload_b64, _ = token.split(".")
145+
146+
# Add padding if needed
147+
padding_needed = len(payload_b64) % 4
148+
if padding_needed:
149+
payload_b64 += "=" * (4 - padding_needed)
150+
151+
# Replace URL-safe characters and decode
152+
payload_b64 = payload_b64.replace("-", "+").replace("_", "/")
153+
decoded = base64.b64decode(payload_b64)
154+
payload = json.loads(decoded)
155+
156+
if DEBUG_MODE:
157+
# Safely log the payload with sensitive information redacted
158+
redacted_payload = payload.copy()
159+
# Redact potentially sensitive fields
160+
for field in ["email", "sub", "at_hash", "nonce"]:
161+
if field in redacted_payload:
162+
redacted_payload[field] = f"<{field}-redacted>"
163+
logger.debug(f"JWT Payload (redacted): {json.dumps(redacted_payload, indent=2)}")
164+
165+
return payload
166+
except Exception as e:
167+
logger.error(f"Error decoding JWT: {e}")
168+
return {}
169+
170+
171+
def extract_user_info(payload):
172+
"""Extract user information from JWT claims"""
173+
# Extract basic user info
174+
email = payload.get("email") or payload.get("preferred_username") or payload.get("mail") or "[email protected]"
175+
176+
# For Cognito, use the sub as user_id and hash it for privacy
177+
user_id = payload.get("sub") or payload.get("user_id") or ""
178+
if user_id:
179+
# Create a consistent hash of the user ID for privacy
180+
user_id_hash = hashlib.sha256(user_id.encode()).hexdigest()[:36]
181+
# Format as UUID-like string
182+
user_id = (
183+
f"{user_id_hash[:8]}-{user_id_hash[8:12]}-{user_id_hash[12:16]}-{user_id_hash[16:20]}-{user_id_hash[20:32]}"
184+
)
185+
186+
# Extract username - for Cognito it's in cognito:username
187+
username = payload.get("cognito:username") or payload.get("preferred_username") or email.split("@")[0]
188+
189+
# Extract organization - derive from issuer or provider
190+
org_id = "amazon-internal" # Default for internal deployment
191+
if payload.get("iss"):
192+
if "okta.com" in payload["iss"]:
193+
org_id = "okta"
194+
elif "auth0.com" in payload["iss"]:
195+
org_id = "auth0"
196+
elif "microsoftonline.com" in payload["iss"]:
197+
org_id = "azure"
198+
199+
# Extract team/department information - these fields vary by IdP
200+
# Provide defaults for consistent metric dimensions
201+
department = payload.get("department") or payload.get("dept") or payload.get("division") or "unspecified"
202+
team = payload.get("team") or payload.get("team_id") or payload.get("group") or "default-team"
203+
cost_center = payload.get("cost_center") or payload.get("costCenter") or payload.get("cost_code") or "general"
204+
manager = payload.get("manager") or payload.get("manager_email") or "unassigned"
205+
location = payload.get("location") or payload.get("office_location") or payload.get("office") or "remote"
206+
role = payload.get("role") or payload.get("job_title") or payload.get("title") or "user"
207+
208+
return {
209+
"email": email,
210+
"user_id": user_id,
211+
"username": username,
212+
"organization_id": org_id,
213+
"department": department,
214+
"team": team,
215+
"cost_center": cost_center,
216+
"manager": manager,
217+
"location": location,
218+
"role": role,
219+
"account_uuid": payload.get("aud", ""),
220+
"issuer": payload.get("iss", ""),
221+
"subject": payload.get("sub", ""),
222+
}
223+
224+
225+
def format_as_headers_dict(attributes):
226+
"""Format attributes as headers dictionary for JSON output"""
227+
# Map attributes to HTTP headers expected by OTEL collector
228+
# Note: Headers must be lowercase to match OTEL collector configuration
229+
header_mapping = {
230+
"email": "x-user-email",
231+
"user_id": "x-user-id",
232+
"username": "x-user-name",
233+
"department": "x-department",
234+
"team": "x-team-id",
235+
"cost_center": "x-cost-center",
236+
"organization_id": "x-organization",
237+
"location": "x-location",
238+
"role": "x-role",
239+
"manager": "x-manager",
240+
}
241+
242+
headers = {}
243+
for attr_key, header_name in header_mapping.items():
244+
if attr_key in attributes and attributes[attr_key]:
245+
headers[header_name] = attributes[attr_key]
246+
247+
return headers
248+
249+
250+
def main():
251+
"""Main function to generate OTEL headers"""
252+
args = parse_args()
253+
254+
# Try to get token from environment first (set by cognito_auth/__main__.py)
255+
token = os.environ.get("CLAUDE_CODE_MONITORING_TOKEN")
256+
if token:
257+
logger.info("Using token from environment variable CLAUDE_CODE_MONITORING_TOKEN")
258+
else:
259+
# Determine which storage method to use
260+
storage_method = args.storage or get_configured_storage_method()
261+
262+
# Get token based on storage method
263+
if storage_method == "keyring":
264+
token = get_token_from_keyring(args.service, args.username)
265+
# If keyring fails, try session file as fallback
266+
if not token and storage_method == "keyring":
267+
logger.info("Keyring access failed, trying session file as fallback")
268+
token = get_token_from_session_file()
269+
elif storage_method == "session":
270+
token = get_token_from_session_file()
271+
else:
272+
logger.warning(f"Unknown storage method: {storage_method}, trying both methods")
273+
token = get_token_from_keyring(args.service, args.username) or get_token_from_session_file()
274+
275+
if not token:
276+
logger.error("No authentication token found")
277+
# Return minimal headers as JSON (flat object with lowercase keys)
278+
if not TEST_MODE:
279+
print(json.dumps({"x-user-email": "[email protected]", "x-user-id": "unknown"}))
280+
return 1
281+
282+
# Decode token and extract user info
283+
try:
284+
payload = decode_jwt_payload(token)
285+
user_info = extract_user_info(payload)
286+
287+
# Generate headers dictionary
288+
headers_dict = format_as_headers_dict(user_info)
289+
290+
# In test mode, print detailed output
291+
if TEST_MODE:
292+
print("===== TEST MODE OUTPUT =====\n")
293+
print("Generated HTTP Headers:")
294+
for header_name, header_value in headers_dict.items():
295+
# Display in uppercase for readability but actual values are lowercase
296+
display_name = header_name.replace("x-", "X-").replace("-id", "-ID")
297+
print(f" {display_name}: {header_value}")
298+
299+
print("\n===== Extracted Attributes =====\n")
300+
for key, value in user_info.items():
301+
if key not in ["account_uuid", "issuer", "subject"]: # Skip technical fields in summary
302+
display_value = value[:30] + "..." if len(str(value)) > 30 else value
303+
print(f" {key.replace('_', '.')}: {display_value}")
304+
305+
# Also show full attributes
306+
print()
307+
print(f" user.email: {user_info['email']}")
308+
print(f" user.id: {user_info['user_id'][:30]}...")
309+
print(f" user.name: {user_info['username']}")
310+
print(f" organization.id: {user_info['organization_id']}")
311+
print(" service.name: claude-code")
312+
print(f" user.account_uuid: {user_info['account_uuid']}")
313+
print(f" oidc.issuer: {user_info['issuer'][:30]}...")
314+
print(f" oidc.subject: {user_info['subject'][:30]}...")
315+
print(f" department: {user_info['department']}")
316+
print(f" team.id: {user_info['team']}")
317+
print(f" cost_center: {user_info['cost_center']}")
318+
print(f" manager: {user_info['manager']}")
319+
print(f" location: {user_info['location']}")
320+
print(f" role: {user_info['role']}")
321+
322+
print("\n========================")
323+
else:
324+
# Normal mode: Output as JSON (flat object with string values)
325+
print(json.dumps(headers_dict))
326+
327+
if DEBUG_MODE or TEST_MODE:
328+
logger.info("Generated OTEL resource attributes:")
329+
if DEBUG_MODE:
330+
logger.debug(f"Attributes: {json.dumps(user_info, indent=2)}")
331+
332+
except Exception as e:
333+
logger.error(f"Error processing token: {e}")
334+
return 1
335+
336+
return 0
337+
338+
339+
if __name__ == "__main__":
340+
sys.exit(main())

0 commit comments

Comments
 (0)