golf-mcp 0.2.16__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. golf/__init__.py +1 -0
  2. golf/auth/__init__.py +277 -0
  3. golf/auth/api_key.py +73 -0
  4. golf/auth/factory.py +360 -0
  5. golf/auth/helpers.py +175 -0
  6. golf/auth/providers.py +586 -0
  7. golf/auth/registry.py +256 -0
  8. golf/cli/__init__.py +1 -0
  9. golf/cli/branding.py +191 -0
  10. golf/cli/main.py +377 -0
  11. golf/commands/__init__.py +5 -0
  12. golf/commands/build.py +81 -0
  13. golf/commands/init.py +290 -0
  14. golf/commands/run.py +137 -0
  15. golf/core/__init__.py +1 -0
  16. golf/core/builder.py +1884 -0
  17. golf/core/builder_auth.py +209 -0
  18. golf/core/builder_metrics.py +221 -0
  19. golf/core/builder_telemetry.py +99 -0
  20. golf/core/config.py +199 -0
  21. golf/core/parser.py +1085 -0
  22. golf/core/telemetry.py +492 -0
  23. golf/core/transformer.py +231 -0
  24. golf/examples/__init__.py +0 -0
  25. golf/examples/basic/.env.example +4 -0
  26. golf/examples/basic/README.md +133 -0
  27. golf/examples/basic/auth.py +76 -0
  28. golf/examples/basic/golf.json +5 -0
  29. golf/examples/basic/prompts/welcome.py +27 -0
  30. golf/examples/basic/resources/current_time.py +34 -0
  31. golf/examples/basic/resources/info.py +28 -0
  32. golf/examples/basic/resources/weather/city.py +46 -0
  33. golf/examples/basic/resources/weather/client.py +48 -0
  34. golf/examples/basic/resources/weather/current.py +36 -0
  35. golf/examples/basic/resources/weather/forecast.py +36 -0
  36. golf/examples/basic/tools/calculator.py +94 -0
  37. golf/examples/basic/tools/say/hello.py +65 -0
  38. golf/metrics/__init__.py +10 -0
  39. golf/metrics/collector.py +320 -0
  40. golf/metrics/registry.py +12 -0
  41. golf/telemetry/__init__.py +23 -0
  42. golf/telemetry/instrumentation.py +1402 -0
  43. golf/utilities/__init__.py +12 -0
  44. golf/utilities/context.py +53 -0
  45. golf/utilities/elicitation.py +170 -0
  46. golf/utilities/sampling.py +221 -0
  47. golf_mcp-0.2.16.dist-info/METADATA +262 -0
  48. golf_mcp-0.2.16.dist-info/RECORD +52 -0
  49. golf_mcp-0.2.16.dist-info/WHEEL +5 -0
  50. golf_mcp-0.2.16.dist-info/entry_points.txt +2 -0
  51. golf_mcp-0.2.16.dist-info/licenses/LICENSE +201 -0
  52. golf_mcp-0.2.16.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1402 @@
1
+ """Component-level OpenTelemetry instrumentation for Golf-built servers."""
2
+
3
+ import asyncio
4
+ import functools
5
+ import os
6
+ import sys
7
+ import time
8
+ import json
9
+ from collections.abc import Callable
10
+ from contextlib import asynccontextmanager
11
+ from typing import Any, TypeVar
12
+ from collections.abc import AsyncGenerator
13
+ from collections import OrderedDict
14
+
15
+ from opentelemetry import baggage, trace
16
+
17
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
18
+ from opentelemetry.sdk.resources import Resource
19
+ from opentelemetry.sdk.trace import TracerProvider
20
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
21
+ from opentelemetry.trace import Status, StatusCode
22
+
23
+ from starlette.middleware.base import BaseHTTPMiddleware
24
+
25
+ T = TypeVar("T")
26
+
27
+ # Global tracer instance
28
+ _tracer: trace.Tracer | None = None
29
+ _provider: TracerProvider | None = None
30
+ _detailed_tracing_enabled: bool = False
31
+
32
+
33
+ def _safe_serialize(data: Any, max_length: int = 1000) -> str | None:
34
+ """Safely serialize data to string with length limit."""
35
+ try:
36
+ if isinstance(data, str):
37
+ serialized = data
38
+ else:
39
+ serialized = json.dumps(data, default=str, ensure_ascii=False)
40
+
41
+ if len(serialized) > max_length:
42
+ return serialized[:max_length] + "..." + f" (truncated from {len(serialized)} chars)"
43
+ return serialized
44
+ except (TypeError, ValueError):
45
+ # Fallback for non-serializable objects
46
+ try:
47
+ return str(data)[:max_length] + "..." if len(str(data)) > max_length else str(data)
48
+ except Exception:
49
+ return None
50
+
51
+
52
+ def set_detailed_tracing(enabled: bool) -> None:
53
+ """Enable or disable detailed tracing with input/output capture."""
54
+ global _detailed_tracing_enabled
55
+ _detailed_tracing_enabled = enabled
56
+
57
+
58
+ def init_telemetry(service_name: str = "golf-mcp-server") -> TracerProvider | None:
59
+ """Initialize OpenTelemetry with environment-based configuration.
60
+
61
+ Returns None if required environment variables are not set.
62
+ """
63
+ global _provider
64
+
65
+ # Check for required environment variables based on exporter type
66
+ exporter_type = os.environ.get("OTEL_TRACES_EXPORTER", "console").lower()
67
+
68
+ # For OTLP HTTP exporter, check if endpoint is configured
69
+ if exporter_type == "otlp_http":
70
+ endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
71
+ if not endpoint:
72
+ print(
73
+ "[WARNING] OpenTelemetry tracing is disabled: "
74
+ "OTEL_EXPORTER_OTLP_ENDPOINT is not set for OTLP HTTP exporter"
75
+ )
76
+ return None
77
+
78
+ # Create resource with service information
79
+ resource_attributes = {
80
+ "service.name": os.environ.get("OTEL_SERVICE_NAME", service_name),
81
+ "service.version": os.environ.get("SERVICE_VERSION", "1.0.0"),
82
+ "service.instance.id": os.environ.get("SERVICE_INSTANCE_ID", "default"),
83
+ }
84
+
85
+ resource = Resource.create(resource_attributes)
86
+
87
+ # Create provider
88
+ provider = TracerProvider(resource=resource)
89
+
90
+ # Configure exporter based on type
91
+ try:
92
+ if exporter_type == "otlp_http":
93
+ endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318/v1/traces")
94
+ headers = os.environ.get("OTEL_EXPORTER_OTLP_HEADERS", "")
95
+
96
+ # Parse headers if provided
97
+ header_dict = {}
98
+ if headers:
99
+ for header in headers.split(","):
100
+ if "=" in header:
101
+ key, value = header.split("=", 1)
102
+ header_dict[key.strip()] = value.strip()
103
+
104
+ exporter = OTLPSpanExporter(endpoint=endpoint, headers=header_dict if header_dict else None)
105
+
106
+ else:
107
+ # Default to console exporter
108
+ exporter = ConsoleSpanExporter(out=sys.stderr)
109
+ except Exception:
110
+ import traceback
111
+
112
+ traceback.print_exc()
113
+ raise
114
+
115
+ # Add batch processor for better performance
116
+ try:
117
+ processor = BatchSpanProcessor(
118
+ exporter,
119
+ max_queue_size=2048,
120
+ schedule_delay_millis=1000, # Export every 1 second instead of
121
+ # default 5 seconds
122
+ max_export_batch_size=512,
123
+ export_timeout_millis=5000,
124
+ )
125
+ provider.add_span_processor(processor)
126
+ except Exception:
127
+ import traceback
128
+
129
+ traceback.print_exc()
130
+ raise
131
+
132
+ # Set as global provider
133
+ try:
134
+ # Check if a provider is already set to avoid the warning
135
+ existing_provider = trace.get_tracer_provider()
136
+ if existing_provider is None or str(type(existing_provider).__name__) == "ProxyTracerProvider":
137
+ # Only set if no provider exists or it's the default proxy provider
138
+ trace.set_tracer_provider(provider)
139
+ _provider = provider
140
+ except Exception:
141
+ import traceback
142
+
143
+ traceback.print_exc()
144
+ raise
145
+
146
+ return provider
147
+
148
+
149
+ def get_tracer() -> trace.Tracer:
150
+ """Get or create the global tracer instance."""
151
+ global _tracer, _provider
152
+
153
+ # If no provider is set, telemetry is disabled - return no-op tracer
154
+ if _provider is None:
155
+ return trace.get_tracer("golf.mcp.components.noop", "1.0.0")
156
+
157
+ if _tracer is None:
158
+ _tracer = trace.get_tracer("golf.mcp.components", "1.0.0")
159
+ return _tracer
160
+
161
+
162
+ def instrument_tool(func: Callable[..., T], tool_name: str) -> Callable[..., T]:
163
+ """Instrument a tool function with OpenTelemetry tracing."""
164
+ global _provider
165
+
166
+ # If telemetry is disabled, return the original function
167
+ if _provider is None:
168
+ return func
169
+
170
+ tracer = get_tracer()
171
+
172
+ @functools.wraps(func)
173
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
174
+ # Record metrics timing
175
+ import time
176
+
177
+ start_time = time.time()
178
+
179
+ # Create a more descriptive span name
180
+ span_name = f"mcp.tool.{tool_name}.execute"
181
+
182
+ # start_as_current_span automatically uses the current context and manages it
183
+ with tracer.start_as_current_span(span_name) as span:
184
+ # Add essential attributes only
185
+ span.set_attribute("mcp.component.type", "tool")
186
+ span.set_attribute("mcp.tool.name", tool_name)
187
+ span.set_attribute(
188
+ "mcp.tool.module",
189
+ func.__module__ if hasattr(func, "__module__") else "unknown",
190
+ )
191
+
192
+ # Add minimal execution context
193
+ if args or kwargs:
194
+ span.set_attribute("mcp.execution.has_params", True)
195
+
196
+ # Capture inputs if detailed tracing is enabled
197
+ if _detailed_tracing_enabled and (args or kwargs):
198
+ input_data = {"args": args, "kwargs": kwargs} if args or kwargs else None
199
+ if input_data:
200
+ input_str = _safe_serialize(input_data)
201
+ if input_str:
202
+ span.set_attribute("mcp.tool.input", input_str)
203
+
204
+ # Extract Context parameter if present
205
+ ctx = kwargs.get("ctx")
206
+ if ctx:
207
+ # Only extract known MCP context attributes
208
+ ctx_attrs = [
209
+ "request_id",
210
+ "session_id",
211
+ "client_id",
212
+ "user_id",
213
+ "tenant_id",
214
+ ]
215
+ for attr in ctx_attrs:
216
+ if hasattr(ctx, attr):
217
+ value = getattr(ctx, attr)
218
+ if value is not None:
219
+ span.set_attribute(f"mcp.context.{attr}", str(value))
220
+
221
+ # Also check baggage for session ID
222
+ session_id_from_baggage = baggage.get_baggage("mcp.session.id")
223
+ if session_id_from_baggage:
224
+ span.set_attribute("mcp.session.id", session_id_from_baggage)
225
+
226
+ # Add event for tool execution start
227
+ span.add_event("tool.execution.started", {"tool.name": tool_name})
228
+
229
+ try:
230
+ result = await func(*args, **kwargs)
231
+ span.set_status(Status(StatusCode.OK))
232
+
233
+ # Add event for successful completion
234
+ span.add_event("tool.execution.completed", {"tool.name": tool_name})
235
+
236
+ # Record metrics for successful execution
237
+ try:
238
+ from golf.metrics import get_metrics_collector
239
+
240
+ metrics_collector = get_metrics_collector()
241
+ metrics_collector.increment_tool_execution(tool_name, "success")
242
+ metrics_collector.record_tool_duration(tool_name, time.time() - start_time)
243
+ except ImportError:
244
+ # Metrics not available, continue without metrics
245
+ pass
246
+
247
+ # Capture result metadata
248
+ if result is not None:
249
+ span.set_attribute("mcp.tool.result.type", type(result).__name__)
250
+
251
+ if isinstance(result, list | dict) and hasattr(result, "__len__"):
252
+ span.set_attribute("mcp.tool.result.size", len(result))
253
+ elif isinstance(result, str):
254
+ span.set_attribute("mcp.tool.result.length", len(result))
255
+
256
+ # Capture full output if detailed tracing is enabled
257
+ if _detailed_tracing_enabled:
258
+ output_str = _safe_serialize(result)
259
+ if output_str:
260
+ span.set_attribute("mcp.tool.output", output_str)
261
+
262
+ return result
263
+ except Exception as e:
264
+ span.record_exception(e)
265
+ span.set_status(Status(StatusCode.ERROR, str(e)))
266
+
267
+ # Add event for error
268
+ span.add_event(
269
+ "tool.execution.error",
270
+ {
271
+ "tool.name": tool_name,
272
+ "error.type": type(e).__name__,
273
+ "error.message": str(e),
274
+ },
275
+ )
276
+
277
+ # Record metrics for failed execution
278
+ try:
279
+ from golf.metrics import get_metrics_collector
280
+
281
+ metrics_collector = get_metrics_collector()
282
+ metrics_collector.increment_tool_execution(tool_name, "error")
283
+ metrics_collector.increment_error("tool", type(e).__name__)
284
+ except ImportError:
285
+ # Metrics not available, continue without metrics
286
+ pass
287
+
288
+ raise
289
+
290
+ @functools.wraps(func)
291
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
292
+ # Record metrics timing
293
+ import time
294
+
295
+ start_time = time.time()
296
+
297
+ # Create a more descriptive span name
298
+ span_name = f"mcp.tool.{tool_name}.execute"
299
+
300
+ # start_as_current_span automatically uses the current context and manages it
301
+ with tracer.start_as_current_span(span_name) as span:
302
+ # Add essential attributes only
303
+ span.set_attribute("mcp.component.type", "tool")
304
+ span.set_attribute("mcp.tool.name", tool_name)
305
+ span.set_attribute(
306
+ "mcp.tool.module",
307
+ func.__module__ if hasattr(func, "__module__") else "unknown",
308
+ )
309
+
310
+ # Add execution context
311
+ span.set_attribute("mcp.execution.args_count", len(args))
312
+ span.set_attribute("mcp.execution.kwargs_count", len(kwargs))
313
+
314
+ # Extract Context parameter if present
315
+ ctx = kwargs.get("ctx")
316
+ if ctx:
317
+ # Only extract known MCP context attributes
318
+ ctx_attrs = [
319
+ "request_id",
320
+ "session_id",
321
+ "client_id",
322
+ "user_id",
323
+ "tenant_id",
324
+ ]
325
+ for attr in ctx_attrs:
326
+ if hasattr(ctx, attr):
327
+ value = getattr(ctx, attr)
328
+ if value is not None:
329
+ span.set_attribute(f"mcp.context.{attr}", str(value))
330
+
331
+ # Also check baggage for session ID
332
+ session_id_from_baggage = baggage.get_baggage("mcp.session.id")
333
+ if session_id_from_baggage:
334
+ span.set_attribute("mcp.session.id", session_id_from_baggage)
335
+
336
+ # Add event for tool execution start
337
+ span.add_event("tool.execution.started", {"tool.name": tool_name})
338
+
339
+ try:
340
+ result = func(*args, **kwargs)
341
+ span.set_status(Status(StatusCode.OK))
342
+
343
+ # Add event for successful completion
344
+ span.add_event("tool.execution.completed", {"tool.name": tool_name})
345
+
346
+ # Record metrics for successful execution
347
+ try:
348
+ from golf.metrics import get_metrics_collector
349
+
350
+ metrics_collector = get_metrics_collector()
351
+ metrics_collector.increment_tool_execution(tool_name, "success")
352
+ metrics_collector.record_tool_duration(tool_name, time.time() - start_time)
353
+ except ImportError:
354
+ # Metrics not available, continue without metrics
355
+ pass
356
+
357
+ # Capture result metadata
358
+ if result is not None:
359
+ span.set_attribute("mcp.tool.result.type", type(result).__name__)
360
+
361
+ if isinstance(result, list | dict) and hasattr(result, "__len__"):
362
+ span.set_attribute("mcp.tool.result.size", len(result))
363
+ elif isinstance(result, str):
364
+ span.set_attribute("mcp.tool.result.length", len(result))
365
+
366
+ # Capture full output if detailed tracing is enabled
367
+ if _detailed_tracing_enabled:
368
+ output_str = _safe_serialize(result)
369
+ if output_str:
370
+ span.set_attribute("mcp.tool.output", output_str)
371
+
372
+ return result
373
+ except Exception as e:
374
+ span.record_exception(e)
375
+ span.set_status(Status(StatusCode.ERROR, str(e)))
376
+
377
+ # Add event for error
378
+ span.add_event(
379
+ "tool.execution.error",
380
+ {
381
+ "tool.name": tool_name,
382
+ "error.type": type(e).__name__,
383
+ "error.message": str(e),
384
+ },
385
+ )
386
+
387
+ # Record metrics for failed execution
388
+ try:
389
+ from golf.metrics import get_metrics_collector
390
+
391
+ metrics_collector = get_metrics_collector()
392
+ metrics_collector.increment_tool_execution(tool_name, "error")
393
+ metrics_collector.increment_error("tool", type(e).__name__)
394
+ except ImportError:
395
+ # Metrics not available, continue without metrics
396
+ pass
397
+
398
+ raise
399
+
400
+ # Return appropriate wrapper based on function type
401
+ if asyncio.iscoroutinefunction(func):
402
+ return async_wrapper
403
+ else:
404
+ return sync_wrapper
405
+
406
+
407
+ def instrument_resource(func: Callable[..., T], resource_uri: str) -> Callable[..., T]:
408
+ """Instrument a resource function with OpenTelemetry tracing."""
409
+ global _provider
410
+
411
+ # If telemetry is disabled, return the original function
412
+ if _provider is None:
413
+ return func
414
+
415
+ tracer = get_tracer()
416
+
417
+ # Determine if this is a template based on URI pattern
418
+ is_template = "{" in resource_uri
419
+
420
+ @functools.wraps(func)
421
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
422
+ # Create a more descriptive span name
423
+ span_name = f"mcp.resource.{'template' if is_template else 'static'}.read"
424
+ with tracer.start_as_current_span(span_name) as span:
425
+ # Add essential attributes only
426
+ span.set_attribute("mcp.component.type", "resource")
427
+ span.set_attribute("mcp.resource.uri", resource_uri)
428
+ span.set_attribute("mcp.resource.is_template", is_template)
429
+ span.set_attribute(
430
+ "mcp.resource.module",
431
+ func.__module__ if hasattr(func, "__module__") else "unknown",
432
+ )
433
+
434
+ # Extract Context parameter if present
435
+ ctx = kwargs.get("ctx")
436
+ if ctx:
437
+ # Only extract known MCP context attributes
438
+ ctx_attrs = [
439
+ "request_id",
440
+ "session_id",
441
+ "client_id",
442
+ "user_id",
443
+ "tenant_id",
444
+ ]
445
+ for attr in ctx_attrs:
446
+ if hasattr(ctx, attr):
447
+ value = getattr(ctx, attr)
448
+ if value is not None:
449
+ span.set_attribute(f"mcp.context.{attr}", str(value))
450
+
451
+ # Also check baggage for session ID
452
+ session_id_from_baggage = baggage.get_baggage("mcp.session.id")
453
+ if session_id_from_baggage:
454
+ span.set_attribute("mcp.session.id", session_id_from_baggage)
455
+
456
+ # Add event for resource read start
457
+ span.add_event("resource.read.started", {"resource.uri": resource_uri})
458
+
459
+ try:
460
+ result = await func(*args, **kwargs)
461
+ span.set_status(Status(StatusCode.OK))
462
+
463
+ # Add event for successful read
464
+ span.add_event("resource.read.completed", {"resource.uri": resource_uri})
465
+
466
+ # Add result metadata
467
+ if hasattr(result, "__len__"):
468
+ span.set_attribute("mcp.resource.result.size", len(result))
469
+
470
+ # Determine content type if possible
471
+ if isinstance(result, str):
472
+ span.set_attribute("mcp.resource.result.type", "text")
473
+ span.set_attribute("mcp.resource.result.length", len(result))
474
+ elif isinstance(result, bytes):
475
+ span.set_attribute("mcp.resource.result.type", "binary")
476
+ span.set_attribute("mcp.resource.result.size_bytes", len(result))
477
+ elif isinstance(result, dict):
478
+ span.set_attribute("mcp.resource.result.type", "object")
479
+ span.set_attribute("mcp.resource.result.keys_count", len(result))
480
+ elif isinstance(result, list):
481
+ span.set_attribute("mcp.resource.result.type", "array")
482
+ span.set_attribute("mcp.resource.result.items_count", len(result))
483
+
484
+ return result
485
+ except Exception as e:
486
+ span.record_exception(e)
487
+ span.set_status(Status(StatusCode.ERROR, str(e)))
488
+
489
+ # Add event for error
490
+ span.add_event(
491
+ "resource.read.error",
492
+ {
493
+ "resource.uri": resource_uri,
494
+ "error.type": type(e).__name__,
495
+ "error.message": str(e),
496
+ },
497
+ )
498
+ raise
499
+
500
+ @functools.wraps(func)
501
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
502
+ # Create a more descriptive span name
503
+ span_name = f"mcp.resource.{'template' if is_template else 'static'}.read"
504
+ with tracer.start_as_current_span(span_name) as span:
505
+ # Add essential attributes only
506
+ span.set_attribute("mcp.component.type", "resource")
507
+ span.set_attribute("mcp.resource.uri", resource_uri)
508
+ span.set_attribute("mcp.resource.is_template", is_template)
509
+ span.set_attribute(
510
+ "mcp.resource.module",
511
+ func.__module__ if hasattr(func, "__module__") else "unknown",
512
+ )
513
+
514
+ # Extract Context parameter if present
515
+ ctx = kwargs.get("ctx")
516
+ if ctx:
517
+ # Only extract known MCP context attributes
518
+ ctx_attrs = [
519
+ "request_id",
520
+ "session_id",
521
+ "client_id",
522
+ "user_id",
523
+ "tenant_id",
524
+ ]
525
+ for attr in ctx_attrs:
526
+ if hasattr(ctx, attr):
527
+ value = getattr(ctx, attr)
528
+ if value is not None:
529
+ span.set_attribute(f"mcp.context.{attr}", str(value))
530
+
531
+ # Also check baggage for session ID
532
+ session_id_from_baggage = baggage.get_baggage("mcp.session.id")
533
+ if session_id_from_baggage:
534
+ span.set_attribute("mcp.session.id", session_id_from_baggage)
535
+
536
+ # Add event for resource read start
537
+ span.add_event("resource.read.started", {"resource.uri": resource_uri})
538
+
539
+ try:
540
+ result = func(*args, **kwargs)
541
+ span.set_status(Status(StatusCode.OK))
542
+
543
+ # Add event for successful read
544
+ span.add_event("resource.read.completed", {"resource.uri": resource_uri})
545
+
546
+ # Add result metadata
547
+ if hasattr(result, "__len__"):
548
+ span.set_attribute("mcp.resource.result.size", len(result))
549
+
550
+ # Determine content type if possible
551
+ if isinstance(result, str):
552
+ span.set_attribute("mcp.resource.result.type", "text")
553
+ span.set_attribute("mcp.resource.result.length", len(result))
554
+ elif isinstance(result, bytes):
555
+ span.set_attribute("mcp.resource.result.type", "binary")
556
+ span.set_attribute("mcp.resource.result.size_bytes", len(result))
557
+ elif isinstance(result, dict):
558
+ span.set_attribute("mcp.resource.result.type", "object")
559
+ span.set_attribute("mcp.resource.result.keys_count", len(result))
560
+ elif isinstance(result, list):
561
+ span.set_attribute("mcp.resource.result.type", "array")
562
+ span.set_attribute("mcp.resource.result.items_count", len(result))
563
+
564
+ return result
565
+ except Exception as e:
566
+ span.record_exception(e)
567
+ span.set_status(Status(StatusCode.ERROR, str(e)))
568
+
569
+ # Add event for error
570
+ span.add_event(
571
+ "resource.read.error",
572
+ {
573
+ "resource.uri": resource_uri,
574
+ "error.type": type(e).__name__,
575
+ "error.message": str(e),
576
+ },
577
+ )
578
+ raise
579
+
580
+ if asyncio.iscoroutinefunction(func):
581
+ return async_wrapper
582
+ else:
583
+ return sync_wrapper
584
+
585
+
586
+ def instrument_elicitation(func: Callable[..., T], elicitation_type: str = "elicit") -> Callable[..., T]:
587
+ """Instrument an elicitation function with OpenTelemetry tracing."""
588
+ global _provider
589
+
590
+ # If telemetry is disabled, return the original function
591
+ if _provider is None:
592
+ return func
593
+
594
+ tracer = get_tracer()
595
+
596
+ @functools.wraps(func)
597
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
598
+ # If telemetry is disabled at runtime, call original function
599
+ global _provider
600
+ if _provider is None:
601
+ return await func(*args, **kwargs)
602
+
603
+ # Record metrics timing
604
+ start_time = time.time()
605
+
606
+ # Create a more descriptive span name
607
+ span_name = f"mcp.elicitation.{elicitation_type}.request"
608
+ with tracer.start_as_current_span(span_name) as span:
609
+ # Add essential attributes
610
+ span.set_attribute("mcp.component.type", "elicitation")
611
+ span.set_attribute("mcp.elicitation.type", elicitation_type)
612
+
613
+ # Capture elicitation parameters if detailed tracing is enabled
614
+ if _detailed_tracing_enabled:
615
+ # Extract message from first argument (common pattern)
616
+ if args:
617
+ message = args[0] if isinstance(args[0], str) else None
618
+ if message:
619
+ span.set_attribute("mcp.elicitation.message", _safe_serialize(message, 500))
620
+
621
+ # Extract response_type from kwargs/args
622
+ response_type = kwargs.get("response_type") or (args[1] if len(args) > 1 else None)
623
+ if response_type is not None:
624
+ if isinstance(response_type, list):
625
+ span.set_attribute("mcp.elicitation.response_type", "choice")
626
+ span.set_attribute("mcp.elicitation.choices", str(response_type))
627
+ elif hasattr(response_type, "__name__"):
628
+ span.set_attribute("mcp.elicitation.response_type", response_type.__name__)
629
+ else:
630
+ span.set_attribute("mcp.elicitation.response_type", str(type(response_type).__name__))
631
+
632
+ # Extract Context parameter if present
633
+ ctx = kwargs.get("ctx")
634
+ if ctx:
635
+ ctx_attrs = ["request_id", "session_id", "client_id", "user_id", "tenant_id"]
636
+ for attr in ctx_attrs:
637
+ if hasattr(ctx, attr):
638
+ value = getattr(ctx, attr)
639
+ if value is not None:
640
+ span.set_attribute(f"mcp.context.{attr}", str(value))
641
+
642
+ # Add event for elicitation start
643
+ span.add_event("elicitation.request.started")
644
+
645
+ try:
646
+ result = await func(*args, **kwargs)
647
+ span.set_status(Status(StatusCode.OK))
648
+
649
+ # Add event for successful completion
650
+ span.add_event("elicitation.request.completed")
651
+
652
+ # Capture result metadata
653
+ if result is not None and _detailed_tracing_enabled:
654
+ if isinstance(result, str):
655
+ span.set_attribute("mcp.elicitation.result.content", _safe_serialize(result, 500))
656
+ elif isinstance(result, (list, dict)) and hasattr(result, "__len__"):
657
+ span.set_attribute("mcp.elicitation.result.size", len(result))
658
+ span.set_attribute("mcp.elicitation.result.content", _safe_serialize(result, 1000))
659
+
660
+ # Record metrics for successful elicitation
661
+ try:
662
+ from golf.metrics import get_metrics_collector
663
+
664
+ metrics_collector = get_metrics_collector()
665
+ metrics_collector.increment_elicitation(elicitation_type, "success")
666
+ metrics_collector.record_elicitation_duration(elicitation_type, time.time() - start_time)
667
+ except ImportError:
668
+ pass
669
+
670
+ return result
671
+ except Exception as e:
672
+ span.record_exception(e)
673
+ span.set_status(Status(StatusCode.ERROR, str(e)))
674
+
675
+ # Add event for error
676
+ span.add_event(
677
+ "elicitation.request.error",
678
+ {
679
+ "error.type": type(e).__name__,
680
+ "error.message": str(e),
681
+ },
682
+ )
683
+
684
+ # Record metrics for failed elicitation
685
+ try:
686
+ from golf.metrics import get_metrics_collector
687
+
688
+ metrics_collector = get_metrics_collector()
689
+ metrics_collector.increment_elicitation(elicitation_type, "error")
690
+ metrics_collector.increment_error("elicitation", type(e).__name__)
691
+ except ImportError:
692
+ pass
693
+
694
+ raise
695
+
696
+ @functools.wraps(func)
697
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
698
+ # If telemetry is disabled at runtime, call original function
699
+ global _provider
700
+ if _provider is None:
701
+ return func(*args, **kwargs)
702
+
703
+ # Record metrics timing
704
+ start_time = time.time()
705
+
706
+ # Create a more descriptive span name
707
+ span_name = f"mcp.elicitation.{elicitation_type}.request"
708
+ with tracer.start_as_current_span(span_name) as span:
709
+ # Add essential attributes
710
+ span.set_attribute("mcp.component.type", "elicitation")
711
+ span.set_attribute("mcp.elicitation.type", elicitation_type)
712
+
713
+ # Capture elicitation parameters if detailed tracing is enabled
714
+ if _detailed_tracing_enabled:
715
+ if args:
716
+ message = args[0] if isinstance(args[0], str) else None
717
+ if message:
718
+ span.set_attribute("mcp.elicitation.message", _safe_serialize(message, 500))
719
+
720
+ # Add event for elicitation start
721
+ span.add_event("elicitation.request.started")
722
+
723
+ try:
724
+ result = func(*args, **kwargs)
725
+ span.set_status(Status(StatusCode.OK))
726
+
727
+ # Add event for successful completion
728
+ span.add_event("elicitation.request.completed")
729
+
730
+ # Record metrics for successful elicitation
731
+ try:
732
+ from golf.metrics import get_metrics_collector
733
+
734
+ metrics_collector = get_metrics_collector()
735
+ metrics_collector.increment_elicitation(elicitation_type, "success")
736
+ metrics_collector.record_elicitation_duration(elicitation_type, time.time() - start_time)
737
+ except ImportError:
738
+ pass
739
+
740
+ return result
741
+ except Exception as e:
742
+ span.record_exception(e)
743
+ span.set_status(Status(StatusCode.ERROR, str(e)))
744
+
745
+ # Add event for error
746
+ span.add_event(
747
+ "elicitation.request.error",
748
+ {
749
+ "error.type": type(e).__name__,
750
+ "error.message": str(e),
751
+ },
752
+ )
753
+
754
+ # Record metrics for failed elicitation
755
+ try:
756
+ from golf.metrics import get_metrics_collector
757
+
758
+ metrics_collector = get_metrics_collector()
759
+ metrics_collector.increment_elicitation(elicitation_type, "error")
760
+ metrics_collector.increment_error("elicitation", type(e).__name__)
761
+ except ImportError:
762
+ pass
763
+
764
+ raise
765
+
766
+ if asyncio.iscoroutinefunction(func):
767
+ return async_wrapper
768
+ else:
769
+ return sync_wrapper
770
+
771
+
772
+ def instrument_sampling(func: Callable[..., T], sampling_type: str = "sample") -> Callable[..., T]:
773
+ """Instrument a sampling function with OpenTelemetry tracing."""
774
+ global _provider
775
+
776
+ # If telemetry is disabled, return the original function
777
+ if _provider is None:
778
+ return func
779
+
780
+ tracer = get_tracer()
781
+
782
+ @functools.wraps(func)
783
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
784
+ # If telemetry is disabled at runtime, call original function
785
+ global _provider
786
+ if _provider is None:
787
+ return await func(*args, **kwargs)
788
+
789
+ # Record metrics timing
790
+ start_time = time.time()
791
+
792
+ # Create a more descriptive span name
793
+ span_name = f"mcp.sampling.{sampling_type}.request"
794
+ with tracer.start_as_current_span(span_name) as span:
795
+ # Add essential attributes
796
+ span.set_attribute("mcp.component.type", "sampling")
797
+ span.set_attribute("mcp.sampling.type", sampling_type)
798
+
799
+ # Capture sampling parameters
800
+ messages = kwargs.get("messages") or (args[0] if args else None)
801
+ if messages and _detailed_tracing_enabled:
802
+ if isinstance(messages, str):
803
+ span.set_attribute("mcp.sampling.messages.content", _safe_serialize(messages, 1000))
804
+ elif isinstance(messages, list):
805
+ span.set_attribute("mcp.sampling.messages.type", "list")
806
+ span.set_attribute("mcp.sampling.messages.count", len(messages))
807
+ span.set_attribute("mcp.sampling.messages.content", _safe_serialize(messages, 1000))
808
+
809
+ # Capture other sampling parameters
810
+ system_prompt = kwargs.get("system_prompt")
811
+ if system_prompt and _detailed_tracing_enabled:
812
+ span.set_attribute("mcp.sampling.system_prompt.length", len(str(system_prompt)))
813
+ span.set_attribute("mcp.sampling.system_prompt.content", _safe_serialize(system_prompt, 500))
814
+
815
+ temperature = kwargs.get("temperature")
816
+ if temperature is not None:
817
+ span.set_attribute("mcp.sampling.temperature", temperature)
818
+
819
+ max_tokens = kwargs.get("max_tokens")
820
+ if max_tokens is not None:
821
+ span.set_attribute("mcp.sampling.max_tokens", max_tokens)
822
+
823
+ model_preferences = kwargs.get("model_preferences")
824
+ if model_preferences:
825
+ if isinstance(model_preferences, str):
826
+ span.set_attribute("mcp.sampling.model_preferences", model_preferences)
827
+ elif isinstance(model_preferences, list):
828
+ span.set_attribute("mcp.sampling.model_preferences", ",".join(model_preferences))
829
+
830
+ # Extract Context parameter if present
831
+ ctx = kwargs.get("ctx")
832
+ if ctx:
833
+ ctx_attrs = ["request_id", "session_id", "client_id", "user_id", "tenant_id"]
834
+ for attr in ctx_attrs:
835
+ if hasattr(ctx, attr):
836
+ value = getattr(ctx, attr)
837
+ if value is not None:
838
+ span.set_attribute(f"mcp.context.{attr}", str(value))
839
+
840
+ # Add event for sampling start
841
+ span.add_event("sampling.request.started")
842
+
843
+ try:
844
+ result = await func(*args, **kwargs)
845
+ span.set_status(Status(StatusCode.OK))
846
+
847
+ # Add event for successful completion
848
+ span.add_event("sampling.request.completed")
849
+
850
+ # Capture result metadata
851
+ if result is not None and _detailed_tracing_enabled and isinstance(result, str):
852
+ span.set_attribute("mcp.sampling.result.content", _safe_serialize(result, 1000))
853
+
854
+ # Record metrics for successful sampling
855
+ try:
856
+ from golf.metrics import get_metrics_collector
857
+
858
+ metrics_collector = get_metrics_collector()
859
+ metrics_collector.increment_sampling(sampling_type, "success")
860
+ metrics_collector.record_sampling_duration(sampling_type, time.time() - start_time)
861
+ if isinstance(result, str):
862
+ metrics_collector.record_sampling_tokens(sampling_type, len(result.split()))
863
+ except ImportError:
864
+ pass
865
+
866
+ return result
867
+ except Exception as e:
868
+ span.record_exception(e)
869
+ span.set_status(Status(StatusCode.ERROR, str(e)))
870
+
871
+ # Add event for error
872
+ span.add_event(
873
+ "sampling.request.error",
874
+ {
875
+ "error.type": type(e).__name__,
876
+ "error.message": str(e),
877
+ },
878
+ )
879
+
880
+ # Record metrics for failed sampling
881
+ try:
882
+ from golf.metrics import get_metrics_collector
883
+
884
+ metrics_collector = get_metrics_collector()
885
+ metrics_collector.increment_sampling(sampling_type, "error")
886
+ metrics_collector.increment_error("sampling", type(e).__name__)
887
+ except ImportError:
888
+ pass
889
+
890
+ raise
891
+
892
+ @functools.wraps(func)
893
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
894
+ # If telemetry is disabled at runtime, call original function
895
+ global _provider
896
+ if _provider is None:
897
+ return func(*args, **kwargs)
898
+
899
+ # Record metrics timing
900
+ start_time = time.time()
901
+
902
+ # Create a more descriptive span name
903
+ span_name = f"mcp.sampling.{sampling_type}.request"
904
+ with tracer.start_as_current_span(span_name) as span:
905
+ # Add essential attributes
906
+ span.set_attribute("mcp.component.type", "sampling")
907
+ span.set_attribute("mcp.sampling.type", sampling_type)
908
+
909
+ # Add event for sampling start
910
+ span.add_event("sampling.request.started")
911
+
912
+ try:
913
+ result = func(*args, **kwargs)
914
+ span.set_status(Status(StatusCode.OK))
915
+
916
+ # Add event for successful completion
917
+ span.add_event("sampling.request.completed")
918
+
919
+ # Record metrics for successful sampling
920
+ try:
921
+ from golf.metrics import get_metrics_collector
922
+
923
+ metrics_collector = get_metrics_collector()
924
+ metrics_collector.increment_sampling(sampling_type, "success")
925
+ metrics_collector.record_sampling_duration(sampling_type, time.time() - start_time)
926
+ except ImportError:
927
+ pass
928
+
929
+ return result
930
+ except Exception as e:
931
+ span.record_exception(e)
932
+ span.set_status(Status(StatusCode.ERROR, str(e)))
933
+
934
+ # Add event for error
935
+ span.add_event(
936
+ "sampling.request.error",
937
+ {
938
+ "error.type": type(e).__name__,
939
+ "error.message": str(e),
940
+ },
941
+ )
942
+ raise
943
+
944
+ if asyncio.iscoroutinefunction(func):
945
+ return async_wrapper
946
+ else:
947
+ return sync_wrapper
948
+
949
+
950
+ def instrument_prompt(func: Callable[..., T], prompt_name: str) -> Callable[..., T]:
951
+ """Instrument a prompt function with OpenTelemetry tracing."""
952
+ global _provider
953
+
954
+ # If telemetry is disabled, return the original function
955
+ if _provider is None:
956
+ return func
957
+
958
+ tracer = get_tracer()
959
+
960
+ @functools.wraps(func)
961
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
962
+ # Create a more descriptive span name
963
+ span_name = f"mcp.prompt.{prompt_name}.generate"
964
+ with tracer.start_as_current_span(span_name) as span:
965
+ # Add essential attributes only
966
+ span.set_attribute("mcp.component.type", "prompt")
967
+ span.set_attribute("mcp.prompt.name", prompt_name)
968
+ span.set_attribute(
969
+ "mcp.prompt.module",
970
+ func.__module__ if hasattr(func, "__module__") else "unknown",
971
+ )
972
+
973
+ # Extract Context parameter if present
974
+ ctx = kwargs.get("ctx")
975
+ if ctx:
976
+ # Only extract known MCP context attributes
977
+ ctx_attrs = [
978
+ "request_id",
979
+ "session_id",
980
+ "client_id",
981
+ "user_id",
982
+ "tenant_id",
983
+ ]
984
+ for attr in ctx_attrs:
985
+ if hasattr(ctx, attr):
986
+ value = getattr(ctx, attr)
987
+ if value is not None:
988
+ span.set_attribute(f"mcp.context.{attr}", str(value))
989
+
990
+ # Also check baggage for session ID
991
+ session_id_from_baggage = baggage.get_baggage("mcp.session.id")
992
+ if session_id_from_baggage:
993
+ span.set_attribute("mcp.session.id", session_id_from_baggage)
994
+
995
+ # Add event for prompt generation start
996
+ span.add_event("prompt.generation.started", {"prompt.name": prompt_name})
997
+
998
+ try:
999
+ result = await func(*args, **kwargs)
1000
+ span.set_status(Status(StatusCode.OK))
1001
+
1002
+ # Add event for successful generation
1003
+ span.add_event("prompt.generation.completed", {"prompt.name": prompt_name})
1004
+
1005
+ # Add message count and type information
1006
+ if isinstance(result, list):
1007
+ span.set_attribute("mcp.prompt.result.message_count", len(result))
1008
+ span.set_attribute("mcp.prompt.result.type", "message_list")
1009
+
1010
+ # Analyze message types if they have role attributes
1011
+ roles = []
1012
+ for msg in result:
1013
+ if hasattr(msg, "role"):
1014
+ roles.append(msg.role)
1015
+ elif isinstance(msg, dict) and "role" in msg:
1016
+ roles.append(msg["role"])
1017
+
1018
+ if roles:
1019
+ unique_roles = list(set(roles))
1020
+ span.set_attribute("mcp.prompt.result.roles", ",".join(unique_roles))
1021
+ span.set_attribute(
1022
+ "mcp.prompt.result.role_counts",
1023
+ str({role: roles.count(role) for role in unique_roles}),
1024
+ )
1025
+ elif isinstance(result, str):
1026
+ span.set_attribute("mcp.prompt.result.type", "string")
1027
+ span.set_attribute("mcp.prompt.result.length", len(result))
1028
+ else:
1029
+ span.set_attribute("mcp.prompt.result.type", type(result).__name__)
1030
+
1031
+ return result
1032
+ except Exception as e:
1033
+ span.record_exception(e)
1034
+ span.set_status(Status(StatusCode.ERROR, str(e)))
1035
+
1036
+ # Add event for error
1037
+ span.add_event(
1038
+ "prompt.generation.error",
1039
+ {
1040
+ "prompt.name": prompt_name,
1041
+ "error.type": type(e).__name__,
1042
+ "error.message": str(e),
1043
+ },
1044
+ )
1045
+ raise
1046
+
1047
+ @functools.wraps(func)
1048
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
1049
+ # Create a more descriptive span name
1050
+ span_name = f"mcp.prompt.{prompt_name}.generate"
1051
+ with tracer.start_as_current_span(span_name) as span:
1052
+ # Add essential attributes only
1053
+ span.set_attribute("mcp.component.type", "prompt")
1054
+ span.set_attribute("mcp.prompt.name", prompt_name)
1055
+ span.set_attribute(
1056
+ "mcp.prompt.module",
1057
+ func.__module__ if hasattr(func, "__module__") else "unknown",
1058
+ )
1059
+
1060
+ # Extract Context parameter if present
1061
+ ctx = kwargs.get("ctx")
1062
+ if ctx:
1063
+ # Only extract known MCP context attributes
1064
+ ctx_attrs = [
1065
+ "request_id",
1066
+ "session_id",
1067
+ "client_id",
1068
+ "user_id",
1069
+ "tenant_id",
1070
+ ]
1071
+ for attr in ctx_attrs:
1072
+ if hasattr(ctx, attr):
1073
+ value = getattr(ctx, attr)
1074
+ if value is not None:
1075
+ span.set_attribute(f"mcp.context.{attr}", str(value))
1076
+
1077
+ # Also check baggage for session ID
1078
+ session_id_from_baggage = baggage.get_baggage("mcp.session.id")
1079
+ if session_id_from_baggage:
1080
+ span.set_attribute("mcp.session.id", session_id_from_baggage)
1081
+
1082
+ # Add event for prompt generation start
1083
+ span.add_event("prompt.generation.started", {"prompt.name": prompt_name})
1084
+
1085
+ try:
1086
+ result = func(*args, **kwargs)
1087
+ span.set_status(Status(StatusCode.OK))
1088
+
1089
+ # Add event for successful generation
1090
+ span.add_event("prompt.generation.completed", {"prompt.name": prompt_name})
1091
+
1092
+ # Add message count and type information
1093
+ if isinstance(result, list):
1094
+ span.set_attribute("mcp.prompt.result.message_count", len(result))
1095
+ span.set_attribute("mcp.prompt.result.type", "message_list")
1096
+
1097
+ # Analyze message types if they have role attributes
1098
+ roles = []
1099
+ for msg in result:
1100
+ if hasattr(msg, "role"):
1101
+ roles.append(msg.role)
1102
+ elif isinstance(msg, dict) and "role" in msg:
1103
+ roles.append(msg["role"])
1104
+
1105
+ if roles:
1106
+ unique_roles = list(set(roles))
1107
+ span.set_attribute("mcp.prompt.result.roles", ",".join(unique_roles))
1108
+ span.set_attribute(
1109
+ "mcp.prompt.result.role_counts",
1110
+ str({role: roles.count(role) for role in unique_roles}),
1111
+ )
1112
+ elif isinstance(result, str):
1113
+ span.set_attribute("mcp.prompt.result.type", "string")
1114
+ span.set_attribute("mcp.prompt.result.length", len(result))
1115
+ else:
1116
+ span.set_attribute("mcp.prompt.result.type", type(result).__name__)
1117
+
1118
+ return result
1119
+ except Exception as e:
1120
+ span.record_exception(e)
1121
+ span.set_status(Status(StatusCode.ERROR, str(e)))
1122
+
1123
+ # Add event for error
1124
+ span.add_event(
1125
+ "prompt.generation.error",
1126
+ {
1127
+ "prompt.name": prompt_name,
1128
+ "error.type": type(e).__name__,
1129
+ "error.message": str(e),
1130
+ },
1131
+ )
1132
+ raise
1133
+
1134
+ if asyncio.iscoroutinefunction(func):
1135
+ return async_wrapper
1136
+ else:
1137
+ return sync_wrapper
1138
+
1139
+
1140
+ # Add the BoundedSessionTracker class before SessionTracingMiddleware
1141
+ class BoundedSessionTracker:
1142
+ """Memory-safe session tracker with automatic expiration."""
1143
+
1144
+ def __init__(self, max_sessions: int = 1000, session_ttl: int = 3600) -> None:
1145
+ self.max_sessions = max_sessions
1146
+ self.session_ttl = session_ttl
1147
+ self.sessions: OrderedDict[str, float] = OrderedDict()
1148
+ self.last_cleanup = time.time()
1149
+
1150
+ def track_session(self, session_id: str) -> bool:
1151
+ """Track a session, returns True if it's new."""
1152
+ current_time = time.time()
1153
+
1154
+ # Periodic cleanup (every 5 minutes)
1155
+ if current_time - self.last_cleanup > 300:
1156
+ self._cleanup_expired(current_time)
1157
+ self.last_cleanup = current_time
1158
+
1159
+ # Check if session exists and is still valid
1160
+ if session_id in self.sessions:
1161
+ # Move to end (mark as recently used)
1162
+ self.sessions.move_to_end(session_id)
1163
+ return False
1164
+
1165
+ # New session
1166
+ self.sessions[session_id] = current_time
1167
+
1168
+ # Enforce max size
1169
+ while len(self.sessions) > self.max_sessions:
1170
+ self.sessions.popitem(last=False) # Remove oldest
1171
+
1172
+ return True
1173
+
1174
+ def _cleanup_expired(self, current_time: float) -> None:
1175
+ """Remove expired sessions."""
1176
+ expired = [sid for sid, timestamp in self.sessions.items() if current_time - timestamp > self.session_ttl]
1177
+ for sid in expired:
1178
+ del self.sessions[sid]
1179
+
1180
+ def get_active_session_count(self) -> int:
1181
+ return len(self.sessions)
1182
+
1183
+
1184
+ class SessionTracingMiddleware(BaseHTTPMiddleware):
1185
+ def __init__(self, app: Any) -> None:
1186
+ super().__init__(app)
1187
+ # Use memory-safe session tracker instead of unbounded collections
1188
+ self.session_tracker = BoundedSessionTracker(max_sessions=1000, session_ttl=3600)
1189
+
1190
+ async def dispatch(self, request: Any, call_next: Callable[..., Any]) -> Any:
1191
+ # Record HTTP request timing
1192
+ import time
1193
+
1194
+ start_time = time.time()
1195
+
1196
+ # Extract session ID from query params or headers
1197
+ session_id = request.query_params.get("session_id")
1198
+ if not session_id:
1199
+ # Check headers as fallback
1200
+ session_id = request.headers.get("x-session-id")
1201
+
1202
+ # Track session metrics using memory-safe tracker
1203
+ if session_id:
1204
+ is_new_session = self.session_tracker.track_session(session_id)
1205
+
1206
+ if is_new_session:
1207
+ try:
1208
+ from golf.metrics import get_metrics_collector
1209
+
1210
+ metrics_collector = get_metrics_collector()
1211
+ metrics_collector.increment_session()
1212
+ except ImportError:
1213
+ pass
1214
+ else:
1215
+ # Record session duration for existing sessions
1216
+ try:
1217
+ from golf.metrics import get_metrics_collector
1218
+
1219
+ metrics_collector = get_metrics_collector()
1220
+ # Use a default duration since we don't track exact start
1221
+ # times anymore
1222
+ # This is less precise but memory-safe
1223
+ metrics_collector.record_session_duration(300.0) # 5 min default
1224
+ except ImportError:
1225
+ pass
1226
+
1227
+ # Create a descriptive span name based on the request
1228
+ method = request.method
1229
+ path = request.url.path
1230
+
1231
+ # Determine the operation type from the path
1232
+ operation_type = "unknown"
1233
+ if "/mcp" in path:
1234
+ operation_type = "mcp.request"
1235
+ elif "/sse" in path:
1236
+ operation_type = "sse.stream"
1237
+ elif "/auth" in path:
1238
+ operation_type = "auth"
1239
+
1240
+ span_name = f"{operation_type}.{method.lower()}"
1241
+
1242
+ tracer = get_tracer()
1243
+ with tracer.start_as_current_span(span_name) as span:
1244
+ # Add essential HTTP attributes
1245
+ span.set_attribute("http.method", method)
1246
+ span.set_attribute("http.target", path)
1247
+ span.set_attribute("http.host", request.url.hostname or "unknown")
1248
+
1249
+ # Add session tracking
1250
+ if session_id:
1251
+ span.set_attribute("mcp.session.id", session_id)
1252
+ span.set_attribute(
1253
+ "mcp.session.active_count",
1254
+ self.session_tracker.get_active_session_count(),
1255
+ )
1256
+ # Add to baggage for propagation
1257
+ ctx = baggage.set_baggage("mcp.session.id", session_id)
1258
+ from opentelemetry import context
1259
+
1260
+ token = context.attach(ctx)
1261
+ else:
1262
+ token = None
1263
+
1264
+ # Add request size if available
1265
+ content_length = request.headers.get("content-length")
1266
+ if content_length:
1267
+ span.set_attribute("http.request.size", int(content_length))
1268
+
1269
+ # Add event for request start
1270
+ span.add_event("http.request.started", {"method": method, "path": path})
1271
+
1272
+ try:
1273
+ response = await call_next(request)
1274
+
1275
+ # Add response attributes
1276
+ span.set_attribute("http.status_code", response.status_code)
1277
+
1278
+ # Set span status based on HTTP status
1279
+ if response.status_code >= 400:
1280
+ span.set_status(Status(StatusCode.ERROR, f"HTTP {response.status_code}"))
1281
+ else:
1282
+ span.set_status(Status(StatusCode.OK))
1283
+
1284
+ # Add event for request completion
1285
+ span.add_event(
1286
+ "http.request.completed",
1287
+ {
1288
+ "method": method,
1289
+ "path": path,
1290
+ "status_code": response.status_code,
1291
+ },
1292
+ )
1293
+
1294
+ # Record HTTP request metrics
1295
+ try:
1296
+ from golf.metrics import get_metrics_collector
1297
+
1298
+ metrics_collector = get_metrics_collector()
1299
+
1300
+ # Clean up path for metrics (remove query params, normalize)
1301
+ clean_path = path.split("?")[0] # Remove query parameters
1302
+ if clean_path.startswith("/"):
1303
+ clean_path = clean_path[1:] or "root" # Remove leading slash, handle root
1304
+
1305
+ metrics_collector.increment_http_request(method, response.status_code, clean_path)
1306
+ metrics_collector.record_http_duration(method, clean_path, time.time() - start_time)
1307
+ except ImportError:
1308
+ # Metrics not available, continue without metrics
1309
+ pass
1310
+
1311
+ return response
1312
+ except Exception as e:
1313
+ span.record_exception(e)
1314
+ span.set_status(Status(StatusCode.ERROR, str(e)))
1315
+
1316
+ # Add event for error
1317
+ span.add_event(
1318
+ "http.request.error",
1319
+ {
1320
+ "method": method,
1321
+ "path": path,
1322
+ "error.type": type(e).__name__,
1323
+ "error.message": str(e),
1324
+ },
1325
+ )
1326
+
1327
+ # Record HTTP error metrics
1328
+ try:
1329
+ from golf.metrics import get_metrics_collector
1330
+
1331
+ metrics_collector = get_metrics_collector()
1332
+
1333
+ # Clean up path for metrics
1334
+ clean_path = path.split("?")[0]
1335
+ if clean_path.startswith("/"):
1336
+ clean_path = clean_path[1:] or "root"
1337
+
1338
+ metrics_collector.increment_http_request(method, 500, clean_path) # Assume 500 for exceptions
1339
+ metrics_collector.increment_error("http", type(e).__name__)
1340
+ except ImportError:
1341
+ pass
1342
+
1343
+ raise
1344
+ finally:
1345
+ if token:
1346
+ context.detach(token)
1347
+
1348
+
1349
+ @asynccontextmanager
1350
+ async def telemetry_lifespan(mcp_instance: Any) -> AsyncGenerator[None, None]:
1351
+ """Simplified lifespan for telemetry initialization and cleanup."""
1352
+ global _provider
1353
+
1354
+ # Initialize telemetry with the server name
1355
+ provider = init_telemetry(service_name=mcp_instance.name)
1356
+
1357
+ # If provider is None, telemetry is disabled
1358
+ if provider is None:
1359
+ # Just yield without any telemetry setup
1360
+ yield
1361
+ return
1362
+
1363
+ # Try to add session tracking middleware if possible
1364
+ try:
1365
+ # Try to add middleware to FastMCP app if it has Starlette app
1366
+ if hasattr(mcp_instance, "app") or hasattr(mcp_instance, "_app"):
1367
+ app = getattr(mcp_instance, "app", getattr(mcp_instance, "_app", None))
1368
+ if app and hasattr(app, "add_middleware"):
1369
+ app.add_middleware(SessionTracingMiddleware)
1370
+
1371
+ # Also try to instrument FastMCP's internal handlers
1372
+ if hasattr(mcp_instance, "_tool_manager") and hasattr(mcp_instance._tool_manager, "tools"):
1373
+ # The tools should already be instrumented when they were registered
1374
+ pass
1375
+
1376
+ # Try to patch FastMCP's request handling to ensure context propagation
1377
+ if hasattr(mcp_instance, "handle_request"):
1378
+ original_handle_request = mcp_instance.handle_request
1379
+
1380
+ async def traced_handle_request(*args: Any, **kwargs: Any) -> Any:
1381
+ tracer = get_tracer()
1382
+ with tracer.start_as_current_span("mcp.handle_request") as span:
1383
+ span.set_attribute("mcp.request.handler", "handle_request")
1384
+ return await original_handle_request(*args, **kwargs)
1385
+
1386
+ mcp_instance.handle_request = traced_handle_request
1387
+
1388
+ except Exception:
1389
+ # Silently continue if middleware setup fails
1390
+ import traceback
1391
+
1392
+ traceback.print_exc()
1393
+
1394
+ try:
1395
+ # Yield control back to FastMCP
1396
+ yield
1397
+ finally:
1398
+ # Cleanup - shutdown the provider
1399
+ if _provider and hasattr(_provider, "shutdown"):
1400
+ _provider.force_flush()
1401
+ _provider.shutdown()
1402
+ _provider = None