golf-mcp 0.1.7__py3-none-any.whl → 0.1.9__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.
Potentially problematic release.
This version of golf-mcp might be problematic. Click here for more details.
- golf/__init__.py +1 -1
- golf/core/builder.py +240 -281
- golf/core/builder_auth.py +149 -43
- golf/core/builder_telemetry.py +44 -179
- golf/core/telemetry.py +46 -8
- golf/examples/api_key/.env.example +5 -0
- golf/examples/api_key/golf.json +3 -1
- golf/examples/basic/.env.example +3 -1
- golf/examples/basic/golf.json +3 -1
- golf/telemetry/__init__.py +19 -0
- golf/telemetry/instrumentation.py +540 -0
- {golf_mcp-0.1.7.dist-info → golf_mcp-0.1.9.dist-info}/METADATA +41 -2
- {golf_mcp-0.1.7.dist-info → golf_mcp-0.1.9.dist-info}/RECORD +17 -14
- {golf_mcp-0.1.7.dist-info → golf_mcp-0.1.9.dist-info}/WHEEL +0 -0
- {golf_mcp-0.1.7.dist-info → golf_mcp-0.1.9.dist-info}/entry_points.txt +0 -0
- {golf_mcp-0.1.7.dist-info → golf_mcp-0.1.9.dist-info}/licenses/LICENSE +0 -0
- {golf_mcp-0.1.7.dist-info → golf_mcp-0.1.9.dist-info}/top_level.txt +0 -0
golf/examples/basic/golf.json
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Golf telemetry module for OpenTelemetry instrumentation."""
|
|
2
|
+
|
|
3
|
+
from golf.telemetry.instrumentation import (
|
|
4
|
+
instrument_tool,
|
|
5
|
+
instrument_resource,
|
|
6
|
+
instrument_prompt,
|
|
7
|
+
telemetry_lifespan,
|
|
8
|
+
init_telemetry,
|
|
9
|
+
get_tracer,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"instrument_tool",
|
|
14
|
+
"instrument_resource",
|
|
15
|
+
"instrument_prompt",
|
|
16
|
+
"telemetry_lifespan",
|
|
17
|
+
"init_telemetry",
|
|
18
|
+
"get_tracer",
|
|
19
|
+
]
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
"""Component-level OpenTelemetry instrumentation for Golf-built servers."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import functools
|
|
6
|
+
from typing import Callable, Optional, TypeVar
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
import asyncio
|
|
9
|
+
|
|
10
|
+
from opentelemetry import trace
|
|
11
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
12
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
|
|
13
|
+
from opentelemetry.sdk.resources import Resource
|
|
14
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
15
|
+
from opentelemetry.trace import Status, StatusCode, Span
|
|
16
|
+
from opentelemetry import baggage
|
|
17
|
+
|
|
18
|
+
T = TypeVar('T')
|
|
19
|
+
|
|
20
|
+
# Global tracer instance
|
|
21
|
+
_tracer: Optional[trace.Tracer] = None
|
|
22
|
+
_provider: Optional[TracerProvider] = None
|
|
23
|
+
_instrumented_tools = []
|
|
24
|
+
|
|
25
|
+
def init_telemetry(service_name: str = "golf-mcp-server") -> Optional[TracerProvider]:
|
|
26
|
+
"""Initialize OpenTelemetry with environment-based configuration.
|
|
27
|
+
|
|
28
|
+
Returns None if required environment variables are not set.
|
|
29
|
+
"""
|
|
30
|
+
global _provider
|
|
31
|
+
|
|
32
|
+
# Check for required environment variables based on exporter type
|
|
33
|
+
exporter_type = os.environ.get("OTEL_TRACES_EXPORTER", "console").lower()
|
|
34
|
+
|
|
35
|
+
# For OTLP HTTP exporter, check if endpoint is configured
|
|
36
|
+
if exporter_type == "otlp_http":
|
|
37
|
+
endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
|
|
38
|
+
if not endpoint:
|
|
39
|
+
print(f"[WARNING] OpenTelemetry tracing is disabled: OTEL_EXPORTER_OTLP_ENDPOINT is not set for OTLP HTTP exporter")
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
# Create resource with service information
|
|
43
|
+
resource_attributes = {
|
|
44
|
+
"service.name": os.environ.get("OTEL_SERVICE_NAME", service_name),
|
|
45
|
+
"service.version": os.environ.get("SERVICE_VERSION", "1.0.0"),
|
|
46
|
+
"service.instance.id": os.environ.get("SERVICE_INSTANCE_ID", "default"),
|
|
47
|
+
}
|
|
48
|
+
resource = Resource.create(resource_attributes)
|
|
49
|
+
|
|
50
|
+
# Create provider
|
|
51
|
+
provider = TracerProvider(resource=resource)
|
|
52
|
+
|
|
53
|
+
# Configure exporter based on type
|
|
54
|
+
try:
|
|
55
|
+
if exporter_type == "otlp_http":
|
|
56
|
+
endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318/v1/traces")
|
|
57
|
+
headers = os.environ.get("OTEL_EXPORTER_OTLP_HEADERS", "")
|
|
58
|
+
|
|
59
|
+
# Parse headers if provided
|
|
60
|
+
header_dict = {}
|
|
61
|
+
if headers:
|
|
62
|
+
for header in headers.split(","):
|
|
63
|
+
if "=" in header:
|
|
64
|
+
key, value = header.split("=", 1)
|
|
65
|
+
header_dict[key.strip()] = value.strip()
|
|
66
|
+
|
|
67
|
+
exporter = OTLPSpanExporter(
|
|
68
|
+
endpoint=endpoint,
|
|
69
|
+
headers=header_dict if header_dict else None
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
# Default to console exporter
|
|
73
|
+
exporter = ConsoleSpanExporter(out=sys.stderr)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
import traceback
|
|
76
|
+
traceback.print_exc()
|
|
77
|
+
raise
|
|
78
|
+
|
|
79
|
+
# Add batch processor for better performance
|
|
80
|
+
try:
|
|
81
|
+
processor = BatchSpanProcessor(
|
|
82
|
+
exporter,
|
|
83
|
+
max_queue_size=2048,
|
|
84
|
+
schedule_delay_millis=1000, # Export every 1 second instead of default 5 seconds
|
|
85
|
+
max_export_batch_size=512,
|
|
86
|
+
export_timeout_millis=5000
|
|
87
|
+
)
|
|
88
|
+
provider.add_span_processor(processor)
|
|
89
|
+
except Exception as e:
|
|
90
|
+
import traceback
|
|
91
|
+
traceback.print_exc()
|
|
92
|
+
raise
|
|
93
|
+
|
|
94
|
+
# Set as global provider
|
|
95
|
+
try:
|
|
96
|
+
# Check if a provider is already set to avoid the warning
|
|
97
|
+
existing_provider = trace.get_tracer_provider()
|
|
98
|
+
if existing_provider is None or str(type(existing_provider).__name__) == 'ProxyTracerProvider':
|
|
99
|
+
# Only set if no provider exists or it's the default proxy provider
|
|
100
|
+
trace.set_tracer_provider(provider)
|
|
101
|
+
_provider = provider
|
|
102
|
+
except Exception as e:
|
|
103
|
+
import traceback
|
|
104
|
+
traceback.print_exc()
|
|
105
|
+
raise
|
|
106
|
+
|
|
107
|
+
# Create a test span to verify everything is working
|
|
108
|
+
try:
|
|
109
|
+
test_tracer = provider.get_tracer("golf.telemetry.test", "1.0.0")
|
|
110
|
+
with test_tracer.start_as_current_span("startup.test") as span:
|
|
111
|
+
span.set_attribute("test", True)
|
|
112
|
+
span.set_attribute("service.name", service_name)
|
|
113
|
+
span.set_attribute("exporter.type", exporter_type)
|
|
114
|
+
span.set_attribute("endpoint", os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "not set"))
|
|
115
|
+
except Exception as e:
|
|
116
|
+
import traceback
|
|
117
|
+
traceback.print_exc()
|
|
118
|
+
|
|
119
|
+
return provider
|
|
120
|
+
|
|
121
|
+
def get_tracer() -> trace.Tracer:
|
|
122
|
+
"""Get or create the global tracer instance."""
|
|
123
|
+
global _tracer, _provider
|
|
124
|
+
|
|
125
|
+
# If no provider is set, telemetry is disabled - return no-op tracer
|
|
126
|
+
if _provider is None:
|
|
127
|
+
return trace.get_tracer("golf.mcp.components.noop", "1.0.0")
|
|
128
|
+
|
|
129
|
+
if _tracer is None:
|
|
130
|
+
_tracer = trace.get_tracer("golf.mcp.components", "1.0.0")
|
|
131
|
+
return _tracer
|
|
132
|
+
|
|
133
|
+
def _add_component_attributes(span: Span, component_type: str, component_name: str, **kwargs):
|
|
134
|
+
"""Add standard component attributes to a span."""
|
|
135
|
+
span.set_attribute("mcp.component.type", component_type)
|
|
136
|
+
span.set_attribute("mcp.component.name", component_name)
|
|
137
|
+
|
|
138
|
+
# Add any additional attributes
|
|
139
|
+
for key, value in kwargs.items():
|
|
140
|
+
if value is not None:
|
|
141
|
+
span.set_attribute(f"mcp.component.{key}", str(value))
|
|
142
|
+
|
|
143
|
+
def instrument_tool(func: Callable[..., T], tool_name: str) -> Callable[..., T]:
|
|
144
|
+
"""Instrument a tool function with OpenTelemetry tracing."""
|
|
145
|
+
global _provider
|
|
146
|
+
|
|
147
|
+
# If telemetry is disabled, return the original function
|
|
148
|
+
if _provider is None:
|
|
149
|
+
return func
|
|
150
|
+
|
|
151
|
+
tracer = get_tracer()
|
|
152
|
+
|
|
153
|
+
@functools.wraps(func)
|
|
154
|
+
async def async_wrapper(*args, **kwargs):
|
|
155
|
+
span = tracer.start_span(f"tool.{tool_name}")
|
|
156
|
+
|
|
157
|
+
# Activate the span in the current context
|
|
158
|
+
from opentelemetry import context
|
|
159
|
+
token = context.attach(trace.set_span_in_context(span))
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
_add_component_attributes(span, "tool", tool_name,
|
|
163
|
+
args_count=len(args),
|
|
164
|
+
kwargs_count=len(kwargs))
|
|
165
|
+
|
|
166
|
+
# Extract Context parameter if present - this should have MCP session info
|
|
167
|
+
ctx = kwargs.get('ctx')
|
|
168
|
+
if ctx:
|
|
169
|
+
if hasattr(ctx, 'request_id'):
|
|
170
|
+
span.set_attribute("mcp.request.id", ctx.request_id)
|
|
171
|
+
if hasattr(ctx, 'session_id'):
|
|
172
|
+
span.set_attribute("mcp.session.id", ctx.session_id)
|
|
173
|
+
# Try to find any session-related attributes
|
|
174
|
+
for attr in dir(ctx):
|
|
175
|
+
if 'session' in attr.lower() and not attr.startswith('_'):
|
|
176
|
+
value = getattr(ctx, attr, None)
|
|
177
|
+
if value:
|
|
178
|
+
span.set_attribute(f"mcp.context.{attr}", str(value))
|
|
179
|
+
|
|
180
|
+
# Also check baggage for session ID
|
|
181
|
+
session_id_from_baggage = baggage.get_baggage("mcp.session.id")
|
|
182
|
+
if session_id_from_baggage:
|
|
183
|
+
span.set_attribute("mcp.session.id", session_id_from_baggage)
|
|
184
|
+
|
|
185
|
+
# Add tool arguments as span attributes (be careful with sensitive data)
|
|
186
|
+
for i, arg in enumerate(args):
|
|
187
|
+
if isinstance(arg, (str, int, float, bool)) or arg is None:
|
|
188
|
+
span.set_attribute(f"tool.arg.{i}", str(arg))
|
|
189
|
+
elif hasattr(arg, '__dict__'):
|
|
190
|
+
# For objects, just record the type
|
|
191
|
+
span.set_attribute(f"tool.arg.{i}.type", type(arg).__name__)
|
|
192
|
+
|
|
193
|
+
# Add named arguments
|
|
194
|
+
for key, value in kwargs.items():
|
|
195
|
+
if key != 'ctx':
|
|
196
|
+
if value is None:
|
|
197
|
+
span.set_attribute(f"tool.kwarg.{key}", "null")
|
|
198
|
+
elif isinstance(value, (str, int, float, bool)):
|
|
199
|
+
span.set_attribute(f"tool.kwarg.{key}", str(value))
|
|
200
|
+
elif isinstance(value, (list, tuple)):
|
|
201
|
+
span.set_attribute(f"tool.kwarg.{key}", f"[{len(value)} items]")
|
|
202
|
+
elif isinstance(value, dict):
|
|
203
|
+
span.set_attribute(f"tool.kwarg.{key}", f"{{dict with {len(value)} keys}}")
|
|
204
|
+
else:
|
|
205
|
+
# For other types, at least record the type
|
|
206
|
+
span.set_attribute(f"tool.kwarg.{key}.type", type(value).__name__)
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
result = await func(*args, **kwargs)
|
|
210
|
+
span.set_status(Status(StatusCode.OK))
|
|
211
|
+
|
|
212
|
+
# Capture result metadata
|
|
213
|
+
if result is not None:
|
|
214
|
+
if isinstance(result, (str, int, float, bool)):
|
|
215
|
+
span.set_attribute("tool.result", str(result))
|
|
216
|
+
elif isinstance(result, list):
|
|
217
|
+
span.set_attribute("tool.result.count", len(result))
|
|
218
|
+
span.set_attribute("tool.result.type", "list")
|
|
219
|
+
elif isinstance(result, dict):
|
|
220
|
+
span.set_attribute("tool.result.keys", ",".join(result.keys()) if len(result) < 10 else f"{len(result)} keys")
|
|
221
|
+
span.set_attribute("tool.result.type", "dict")
|
|
222
|
+
elif hasattr(result, '__len__'):
|
|
223
|
+
span.set_attribute("tool.result.length", len(result))
|
|
224
|
+
|
|
225
|
+
# For any result, record its type
|
|
226
|
+
span.set_attribute("tool.result.class", type(result).__name__)
|
|
227
|
+
|
|
228
|
+
return result
|
|
229
|
+
except Exception as e:
|
|
230
|
+
span.record_exception(e)
|
|
231
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
232
|
+
raise
|
|
233
|
+
finally:
|
|
234
|
+
# End the span and detach context
|
|
235
|
+
span.end()
|
|
236
|
+
context.detach(token)
|
|
237
|
+
|
|
238
|
+
# Force flush the provider to ensure spans are exported
|
|
239
|
+
global _provider
|
|
240
|
+
if _provider:
|
|
241
|
+
try:
|
|
242
|
+
_provider.force_flush(timeout_millis=1000)
|
|
243
|
+
except Exception as e:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
@functools.wraps(func)
|
|
247
|
+
def sync_wrapper(*args, **kwargs):
|
|
248
|
+
span = tracer.start_span(f"tool.{tool_name}")
|
|
249
|
+
|
|
250
|
+
# Activate the span in the current context
|
|
251
|
+
from opentelemetry import context
|
|
252
|
+
token = context.attach(trace.set_span_in_context(span))
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
_add_component_attributes(span, "tool", tool_name,
|
|
256
|
+
args_count=len(args),
|
|
257
|
+
kwargs_count=len(kwargs))
|
|
258
|
+
|
|
259
|
+
# Extract Context parameter if present - this should have MCP session info
|
|
260
|
+
ctx = kwargs.get('ctx')
|
|
261
|
+
if ctx:
|
|
262
|
+
if hasattr(ctx, 'request_id'):
|
|
263
|
+
span.set_attribute("mcp.request.id", ctx.request_id)
|
|
264
|
+
if hasattr(ctx, 'session_id'):
|
|
265
|
+
span.set_attribute("mcp.session.id", ctx.session_id)
|
|
266
|
+
# Try to find any session-related attributes
|
|
267
|
+
for attr in dir(ctx):
|
|
268
|
+
if 'session' in attr.lower() and not attr.startswith('_'):
|
|
269
|
+
value = getattr(ctx, attr, None)
|
|
270
|
+
if value:
|
|
271
|
+
span.set_attribute(f"mcp.context.{attr}", str(value))
|
|
272
|
+
|
|
273
|
+
# Also check baggage for session ID
|
|
274
|
+
session_id_from_baggage = baggage.get_baggage("mcp.session.id")
|
|
275
|
+
if session_id_from_baggage:
|
|
276
|
+
span.set_attribute("mcp.session.id", session_id_from_baggage)
|
|
277
|
+
|
|
278
|
+
# Add tool arguments as span attributes (be careful with sensitive data)
|
|
279
|
+
for i, arg in enumerate(args):
|
|
280
|
+
if isinstance(arg, (str, int, float, bool)) or arg is None:
|
|
281
|
+
span.set_attribute(f"tool.arg.{i}", str(arg))
|
|
282
|
+
elif hasattr(arg, '__dict__'):
|
|
283
|
+
# For objects, just record the type
|
|
284
|
+
span.set_attribute(f"tool.arg.{i}.type", type(arg).__name__)
|
|
285
|
+
|
|
286
|
+
# Add named arguments
|
|
287
|
+
for key, value in kwargs.items():
|
|
288
|
+
if key != 'ctx':
|
|
289
|
+
if value is None:
|
|
290
|
+
span.set_attribute(f"tool.kwarg.{key}", "null")
|
|
291
|
+
elif isinstance(value, (str, int, float, bool)):
|
|
292
|
+
span.set_attribute(f"tool.kwarg.{key}", str(value))
|
|
293
|
+
elif isinstance(value, (list, tuple)):
|
|
294
|
+
span.set_attribute(f"tool.kwarg.{key}", f"[{len(value)} items]")
|
|
295
|
+
elif isinstance(value, dict):
|
|
296
|
+
span.set_attribute(f"tool.kwarg.{key}", f"{{dict with {len(value)} keys}}")
|
|
297
|
+
else:
|
|
298
|
+
# For other types, at least record the type
|
|
299
|
+
span.set_attribute(f"tool.kwarg.{key}.type", type(value).__name__)
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
result = func(*args, **kwargs)
|
|
303
|
+
span.set_status(Status(StatusCode.OK))
|
|
304
|
+
|
|
305
|
+
# Capture result metadata
|
|
306
|
+
if result is not None:
|
|
307
|
+
if isinstance(result, (str, int, float, bool)):
|
|
308
|
+
span.set_attribute("tool.result", str(result))
|
|
309
|
+
elif isinstance(result, list):
|
|
310
|
+
span.set_attribute("tool.result.count", len(result))
|
|
311
|
+
span.set_attribute("tool.result.type", "list")
|
|
312
|
+
elif isinstance(result, dict):
|
|
313
|
+
span.set_attribute("tool.result.keys", ",".join(result.keys()) if len(result) < 10 else f"{len(result)} keys")
|
|
314
|
+
span.set_attribute("tool.result.type", "dict")
|
|
315
|
+
elif hasattr(result, '__len__'):
|
|
316
|
+
span.set_attribute("tool.result.length", len(result))
|
|
317
|
+
|
|
318
|
+
# For any result, record its type
|
|
319
|
+
span.set_attribute("tool.result.class", type(result).__name__)
|
|
320
|
+
|
|
321
|
+
return result
|
|
322
|
+
except Exception as e:
|
|
323
|
+
span.record_exception(e)
|
|
324
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
325
|
+
raise
|
|
326
|
+
finally:
|
|
327
|
+
# End the span and detach context
|
|
328
|
+
span.end()
|
|
329
|
+
context.detach(token)
|
|
330
|
+
|
|
331
|
+
# Force flush the provider to ensure spans are exported
|
|
332
|
+
global _provider
|
|
333
|
+
if _provider:
|
|
334
|
+
try:
|
|
335
|
+
_provider.force_flush(timeout_millis=1000)
|
|
336
|
+
except Exception as e:
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
# Return appropriate wrapper based on function type
|
|
340
|
+
if asyncio.iscoroutinefunction(func):
|
|
341
|
+
return async_wrapper
|
|
342
|
+
else:
|
|
343
|
+
return sync_wrapper
|
|
344
|
+
|
|
345
|
+
def instrument_resource(func: Callable[..., T], resource_uri: str) -> Callable[..., T]:
|
|
346
|
+
"""Instrument a resource function with OpenTelemetry tracing."""
|
|
347
|
+
global _provider
|
|
348
|
+
|
|
349
|
+
# If telemetry is disabled, return the original function
|
|
350
|
+
if _provider is None:
|
|
351
|
+
return func
|
|
352
|
+
|
|
353
|
+
tracer = get_tracer()
|
|
354
|
+
|
|
355
|
+
# Determine if this is a template based on URI pattern
|
|
356
|
+
is_template = '{' in resource_uri
|
|
357
|
+
|
|
358
|
+
@functools.wraps(func)
|
|
359
|
+
async def async_wrapper(*args, **kwargs):
|
|
360
|
+
span_name = "resource.template.read" if is_template else "resource.read"
|
|
361
|
+
with tracer.start_as_current_span(span_name) as span:
|
|
362
|
+
_add_component_attributes(span, "resource", resource_uri,
|
|
363
|
+
is_template=is_template)
|
|
364
|
+
|
|
365
|
+
# Extract Context parameter if present
|
|
366
|
+
ctx = kwargs.get('ctx')
|
|
367
|
+
if ctx and hasattr(ctx, 'request_id'):
|
|
368
|
+
span.set_attribute("mcp.request.id", ctx.request_id)
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
result = await func(*args, **kwargs)
|
|
372
|
+
span.set_status(Status(StatusCode.OK))
|
|
373
|
+
|
|
374
|
+
# Add result size if applicable
|
|
375
|
+
if hasattr(result, '__len__'):
|
|
376
|
+
span.set_attribute("mcp.resource.size", len(result))
|
|
377
|
+
|
|
378
|
+
return result
|
|
379
|
+
except Exception as e:
|
|
380
|
+
span.record_exception(e)
|
|
381
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
382
|
+
raise
|
|
383
|
+
|
|
384
|
+
@functools.wraps(func)
|
|
385
|
+
def sync_wrapper(*args, **kwargs):
|
|
386
|
+
span_name = "resource.template.read" if is_template else "resource.read"
|
|
387
|
+
with tracer.start_as_current_span(span_name) as span:
|
|
388
|
+
_add_component_attributes(span, "resource", resource_uri,
|
|
389
|
+
is_template=is_template)
|
|
390
|
+
|
|
391
|
+
# Extract Context parameter if present
|
|
392
|
+
ctx = kwargs.get('ctx')
|
|
393
|
+
if ctx and hasattr(ctx, 'request_id'):
|
|
394
|
+
span.set_attribute("mcp.request.id", ctx.request_id)
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
result = func(*args, **kwargs)
|
|
398
|
+
span.set_status(Status(StatusCode.OK))
|
|
399
|
+
|
|
400
|
+
# Add result size if applicable
|
|
401
|
+
if hasattr(result, '__len__'):
|
|
402
|
+
span.set_attribute("mcp.resource.size", len(result))
|
|
403
|
+
|
|
404
|
+
return result
|
|
405
|
+
except Exception as e:
|
|
406
|
+
span.record_exception(e)
|
|
407
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
408
|
+
raise
|
|
409
|
+
|
|
410
|
+
if asyncio.iscoroutinefunction(func):
|
|
411
|
+
return async_wrapper
|
|
412
|
+
else:
|
|
413
|
+
return sync_wrapper
|
|
414
|
+
|
|
415
|
+
def instrument_prompt(func: Callable[..., T], prompt_name: str) -> Callable[..., T]:
|
|
416
|
+
"""Instrument a prompt function with OpenTelemetry tracing."""
|
|
417
|
+
global _provider
|
|
418
|
+
|
|
419
|
+
# If telemetry is disabled, return the original function
|
|
420
|
+
if _provider is None:
|
|
421
|
+
return func
|
|
422
|
+
|
|
423
|
+
tracer = get_tracer()
|
|
424
|
+
|
|
425
|
+
@functools.wraps(func)
|
|
426
|
+
async def async_wrapper(*args, **kwargs):
|
|
427
|
+
with tracer.start_as_current_span(f"prompt.{prompt_name}") as span:
|
|
428
|
+
_add_component_attributes(span, "prompt", prompt_name)
|
|
429
|
+
|
|
430
|
+
# Extract Context parameter if present
|
|
431
|
+
ctx = kwargs.get('ctx')
|
|
432
|
+
if ctx and hasattr(ctx, 'request_id'):
|
|
433
|
+
span.set_attribute("mcp.request.id", ctx.request_id)
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
result = await func(*args, **kwargs)
|
|
437
|
+
span.set_status(Status(StatusCode.OK))
|
|
438
|
+
|
|
439
|
+
# Add message count if result is a list
|
|
440
|
+
if isinstance(result, list):
|
|
441
|
+
span.set_attribute("mcp.prompt.message_count", len(result))
|
|
442
|
+
|
|
443
|
+
return result
|
|
444
|
+
except Exception as e:
|
|
445
|
+
span.record_exception(e)
|
|
446
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
447
|
+
raise
|
|
448
|
+
|
|
449
|
+
@functools.wraps(func)
|
|
450
|
+
def sync_wrapper(*args, **kwargs):
|
|
451
|
+
with tracer.start_as_current_span(f"prompt.{prompt_name}") as span:
|
|
452
|
+
_add_component_attributes(span, "prompt", prompt_name)
|
|
453
|
+
|
|
454
|
+
# Extract Context parameter if present
|
|
455
|
+
ctx = kwargs.get('ctx')
|
|
456
|
+
if ctx and hasattr(ctx, 'request_id'):
|
|
457
|
+
span.set_attribute("mcp.request.id", ctx.request_id)
|
|
458
|
+
|
|
459
|
+
try:
|
|
460
|
+
result = func(*args, **kwargs)
|
|
461
|
+
span.set_status(Status(StatusCode.OK))
|
|
462
|
+
|
|
463
|
+
# Add message count if result is a list
|
|
464
|
+
if isinstance(result, list):
|
|
465
|
+
span.set_attribute("mcp.prompt.message_count", len(result))
|
|
466
|
+
|
|
467
|
+
return result
|
|
468
|
+
except Exception as e:
|
|
469
|
+
span.record_exception(e)
|
|
470
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
471
|
+
raise
|
|
472
|
+
|
|
473
|
+
if asyncio.iscoroutinefunction(func):
|
|
474
|
+
return async_wrapper
|
|
475
|
+
else:
|
|
476
|
+
return sync_wrapper
|
|
477
|
+
|
|
478
|
+
@asynccontextmanager
|
|
479
|
+
async def telemetry_lifespan(mcp_instance):
|
|
480
|
+
"""Simplified lifespan for telemetry initialization and cleanup."""
|
|
481
|
+
global _provider, _instrumented_tools
|
|
482
|
+
|
|
483
|
+
# Initialize telemetry with the server name
|
|
484
|
+
provider = init_telemetry(service_name=mcp_instance.name)
|
|
485
|
+
|
|
486
|
+
# If provider is None, telemetry is disabled
|
|
487
|
+
if provider is None:
|
|
488
|
+
# Just yield without any telemetry setup
|
|
489
|
+
yield
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
# Try to add session tracking middleware if possible
|
|
493
|
+
try:
|
|
494
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
495
|
+
from starlette.requests import Request
|
|
496
|
+
|
|
497
|
+
class SessionTracingMiddleware(BaseHTTPMiddleware):
|
|
498
|
+
async def dispatch(self, request: Request, call_next):
|
|
499
|
+
# Extract session ID from query params
|
|
500
|
+
session_id = request.query_params.get('session_id')
|
|
501
|
+
if session_id:
|
|
502
|
+
# Add to baggage for propagation
|
|
503
|
+
ctx = baggage.set_baggage("mcp.session.id", session_id)
|
|
504
|
+
from opentelemetry import context
|
|
505
|
+
token = context.attach(ctx)
|
|
506
|
+
|
|
507
|
+
# Also create a span for the HTTP request
|
|
508
|
+
tracer = get_tracer()
|
|
509
|
+
with tracer.start_as_current_span(f"http.{request.method} {request.url.path}") as span:
|
|
510
|
+
span.set_attribute("http.method", request.method)
|
|
511
|
+
span.set_attribute("http.url", str(request.url))
|
|
512
|
+
span.set_attribute("http.session_id", session_id)
|
|
513
|
+
span.set_attribute("mcp.session.id", session_id)
|
|
514
|
+
|
|
515
|
+
try:
|
|
516
|
+
response = await call_next(request)
|
|
517
|
+
span.set_attribute("http.status_code", response.status_code)
|
|
518
|
+
return response
|
|
519
|
+
finally:
|
|
520
|
+
context.detach(token)
|
|
521
|
+
else:
|
|
522
|
+
return await call_next(request)
|
|
523
|
+
|
|
524
|
+
# Try to add middleware to FastMCP app if it has Starlette app
|
|
525
|
+
if hasattr(mcp_instance, 'app') or hasattr(mcp_instance, '_app'):
|
|
526
|
+
app = getattr(mcp_instance, 'app', getattr(mcp_instance, '_app', None))
|
|
527
|
+
if app and hasattr(app, 'add_middleware'):
|
|
528
|
+
app.add_middleware(SessionTracingMiddleware)
|
|
529
|
+
except Exception:
|
|
530
|
+
pass
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
# Yield control back to FastMCP
|
|
534
|
+
yield
|
|
535
|
+
finally:
|
|
536
|
+
# Cleanup - shutdown the provider
|
|
537
|
+
if _provider and hasattr(_provider, 'shutdown'):
|
|
538
|
+
_provider.force_flush()
|
|
539
|
+
_provider.shutdown()
|
|
540
|
+
_provider = None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: golf-mcp
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.9
|
|
4
4
|
Summary: Framework for building MCP servers
|
|
5
5
|
Author-email: Antoni Gmitruk <antoni@golf.dev>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -177,6 +177,11 @@ The `golf.json` file is the heart of your Golf project configuration. Here's wha
|
|
|
177
177
|
// - "sse": Server-Sent Events (recommended for web clients)
|
|
178
178
|
// - "streamable-http": HTTP with streaming support
|
|
179
179
|
// - "stdio": Standard I/O (for CLI integration)
|
|
180
|
+
|
|
181
|
+
// OpenTelemetry Configuration (optional)
|
|
182
|
+
"opentelemetry_enabled": false, // Enable distributed tracing
|
|
183
|
+
"opentelemetry_default_exporter": "console" // Default exporter if OTEL_TRACES_EXPORTER not set
|
|
184
|
+
// Options: "console", "otlp_http"
|
|
180
185
|
}
|
|
181
186
|
```
|
|
182
187
|
|
|
@@ -188,12 +193,46 @@ The `golf.json` file is the heart of your Golf project configuration. Here's wha
|
|
|
188
193
|
- `"streamable-http"` provides HTTP streaming for traditional API clients
|
|
189
194
|
- `"stdio"` enables integration with command-line tools and scripts
|
|
190
195
|
- **`host` & `port`**: Control where your server listens. Use `"127.0.0.1"` for local development or `"0.0.0.0"` to accept external connections.
|
|
196
|
+
- **`opentelemetry_enabled`**: When true, enables distributed tracing for debugging and monitoring your MCP server
|
|
197
|
+
- **`opentelemetry_default_exporter`**: Sets the default trace exporter. Can be overridden by the `OTEL_TRACES_EXPORTER` environment variable
|
|
198
|
+
|
|
199
|
+
## Features
|
|
200
|
+
|
|
201
|
+
### 🔍 OpenTelemetry Support
|
|
202
|
+
|
|
203
|
+
Golf includes built-in OpenTelemetry instrumentation for distributed tracing. When enabled, it automatically traces:
|
|
204
|
+
- Tool executions with arguments and results
|
|
205
|
+
- Resource reads and template expansions
|
|
206
|
+
- Prompt generations
|
|
207
|
+
- HTTP requests and sessions
|
|
208
|
+
|
|
209
|
+
#### Configuration
|
|
210
|
+
|
|
211
|
+
Enable OpenTelemetry in your `golf.json`:
|
|
212
|
+
```json
|
|
213
|
+
{
|
|
214
|
+
"opentelemetry_enabled": true,
|
|
215
|
+
"opentelemetry_default_exporter": "otlp_http"
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Then configure via environment variables:
|
|
220
|
+
```bash
|
|
221
|
+
# For OTLP HTTP exporter (e.g., Jaeger, Grafana Tempo)
|
|
222
|
+
OTEL_TRACES_EXPORTER=otlp_http
|
|
223
|
+
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces
|
|
224
|
+
OTEL_SERVICE_NAME=my-golf-server # Optional, defaults to project name
|
|
225
|
+
|
|
226
|
+
# For console exporter (debugging)
|
|
227
|
+
OTEL_TRACES_EXPORTER=console
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
**Note**: When using the OTLP HTTP exporter, you must set `OTEL_EXPORTER_OTLP_ENDPOINT`. If not configured, Golf will display a warning and disable tracing to avoid errors.
|
|
191
231
|
|
|
192
232
|
## Roadmap
|
|
193
233
|
|
|
194
234
|
Here are the things we are working hard on:
|
|
195
235
|
|
|
196
|
-
* **Native OpenTelemetry implementation for tracing**
|
|
197
236
|
* **`golf deploy` command for one click deployments to Vercel, Blaxel and other providers**
|
|
198
237
|
* **Production-ready OAuth token management, to allow for persistent, encrypted token storage and client mapping**
|
|
199
238
|
|