robotframework-tracer 0.1.0__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.
- robotframework_tracer/__init__.py +6 -0
- robotframework_tracer/attributes.py +108 -0
- robotframework_tracer/config.py +63 -0
- robotframework_tracer/listener.py +236 -0
- robotframework_tracer/span_builder.py +128 -0
- robotframework_tracer/version.py +1 -0
- robotframework_tracer-0.1.0.dist-info/METADATA +242 -0
- robotframework_tracer-0.1.0.dist-info/RECORD +10 -0
- robotframework_tracer-0.1.0.dist-info/WHEEL +5 -0
- robotframework_tracer-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
class RFAttributes:
|
|
2
|
+
"""OpenTelemetry semantic conventions for Robot Framework."""
|
|
3
|
+
|
|
4
|
+
# Suite attributes
|
|
5
|
+
SUITE_NAME = "rf.suite.name"
|
|
6
|
+
SUITE_SOURCE = "rf.suite.source"
|
|
7
|
+
SUITE_ID = "rf.suite.id"
|
|
8
|
+
SUITE_METADATA = "rf.suite.metadata"
|
|
9
|
+
|
|
10
|
+
# Test attributes
|
|
11
|
+
TEST_NAME = "rf.test.name"
|
|
12
|
+
TEST_ID = "rf.test.id"
|
|
13
|
+
TEST_TAGS = "rf.test.tags"
|
|
14
|
+
TEST_TEMPLATE = "rf.test.template"
|
|
15
|
+
TEST_TIMEOUT = "rf.test.timeout"
|
|
16
|
+
|
|
17
|
+
# Keyword attributes
|
|
18
|
+
KEYWORD_NAME = "rf.keyword.name"
|
|
19
|
+
KEYWORD_TYPE = "rf.keyword.type"
|
|
20
|
+
KEYWORD_LIBRARY = "rf.keyword.library"
|
|
21
|
+
KEYWORD_ARGS = "rf.keyword.args"
|
|
22
|
+
|
|
23
|
+
# Result attributes
|
|
24
|
+
STATUS = "rf.status"
|
|
25
|
+
ELAPSED_TIME = "rf.elapsed_time"
|
|
26
|
+
START_TIME = "rf.start_time"
|
|
27
|
+
END_TIME = "rf.end_time"
|
|
28
|
+
MESSAGE = "rf.message"
|
|
29
|
+
|
|
30
|
+
# Framework attributes
|
|
31
|
+
RF_VERSION = "rf.version"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AttributeExtractor:
|
|
35
|
+
"""Extract attributes from Robot Framework objects."""
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def from_suite(data, result):
|
|
39
|
+
"""Extract attributes from suite data and result."""
|
|
40
|
+
attrs = {
|
|
41
|
+
RFAttributes.SUITE_NAME: data.name,
|
|
42
|
+
RFAttributes.SUITE_ID: result.id,
|
|
43
|
+
}
|
|
44
|
+
if data.source:
|
|
45
|
+
attrs[RFAttributes.SUITE_SOURCE] = str(data.source)
|
|
46
|
+
|
|
47
|
+
# Extract suite metadata
|
|
48
|
+
if hasattr(data, 'metadata') and data.metadata:
|
|
49
|
+
for key, value in data.metadata.items():
|
|
50
|
+
attrs[f"{RFAttributes.SUITE_METADATA}.{key}"] = str(value)
|
|
51
|
+
|
|
52
|
+
# Add timing information
|
|
53
|
+
if hasattr(result, 'starttime') and result.starttime:
|
|
54
|
+
attrs[RFAttributes.START_TIME] = result.starttime
|
|
55
|
+
if hasattr(result, 'endtime') and result.endtime:
|
|
56
|
+
attrs[RFAttributes.END_TIME] = result.endtime
|
|
57
|
+
|
|
58
|
+
return attrs
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def from_test(data, result):
|
|
62
|
+
"""Extract attributes from test data and result."""
|
|
63
|
+
attrs = {
|
|
64
|
+
RFAttributes.TEST_NAME: data.name,
|
|
65
|
+
RFAttributes.TEST_ID: result.id,
|
|
66
|
+
}
|
|
67
|
+
if data.tags:
|
|
68
|
+
attrs[RFAttributes.TEST_TAGS] = list(data.tags)
|
|
69
|
+
|
|
70
|
+
# Add test template if available
|
|
71
|
+
if hasattr(data, 'template') and data.template:
|
|
72
|
+
attrs[RFAttributes.TEST_TEMPLATE] = str(data.template)
|
|
73
|
+
|
|
74
|
+
# Add test timeout if available
|
|
75
|
+
if hasattr(data, 'timeout') and data.timeout:
|
|
76
|
+
attrs[RFAttributes.TEST_TIMEOUT] = str(data.timeout)
|
|
77
|
+
|
|
78
|
+
# Add timing information
|
|
79
|
+
if hasattr(result, 'starttime') and result.starttime:
|
|
80
|
+
attrs[RFAttributes.START_TIME] = result.starttime
|
|
81
|
+
if hasattr(result, 'endtime') and result.endtime:
|
|
82
|
+
attrs[RFAttributes.END_TIME] = result.endtime
|
|
83
|
+
|
|
84
|
+
# Add message if available
|
|
85
|
+
if hasattr(result, 'message') and result.message:
|
|
86
|
+
attrs[RFAttributes.MESSAGE] = result.message
|
|
87
|
+
|
|
88
|
+
return attrs
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def from_keyword(data, result, max_arg_length=200):
|
|
92
|
+
"""Extract attributes from keyword data and result."""
|
|
93
|
+
attrs = {
|
|
94
|
+
RFAttributes.KEYWORD_NAME: data.name,
|
|
95
|
+
RFAttributes.KEYWORD_TYPE: data.type,
|
|
96
|
+
}
|
|
97
|
+
# Try to get library name (may not always be available)
|
|
98
|
+
if hasattr(data, 'libname') and data.libname:
|
|
99
|
+
attrs[RFAttributes.KEYWORD_LIBRARY] = data.libname
|
|
100
|
+
elif hasattr(data, 'owner') and data.owner:
|
|
101
|
+
attrs[RFAttributes.KEYWORD_LIBRARY] = data.owner.name if hasattr(data.owner, 'name') else str(data.owner)
|
|
102
|
+
|
|
103
|
+
if data.args:
|
|
104
|
+
args_str = ", ".join(str(arg)[:max_arg_length] for arg in data.args[:10])
|
|
105
|
+
if len(args_str) > max_arg_length:
|
|
106
|
+
args_str = args_str[:max_arg_length] + "..."
|
|
107
|
+
attrs[RFAttributes.KEYWORD_ARGS] = args_str
|
|
108
|
+
return attrs
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TracerConfig:
|
|
5
|
+
"""Configuration for the Robot Framework tracer."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, **kwargs):
|
|
8
|
+
self.endpoint = self._get_config(
|
|
9
|
+
"endpoint", kwargs, "OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318/v1/traces"
|
|
10
|
+
)
|
|
11
|
+
self.service_name = self._get_config(
|
|
12
|
+
"service_name", kwargs, "OTEL_SERVICE_NAME", "rf"
|
|
13
|
+
)
|
|
14
|
+
self.protocol = self._get_config("protocol", kwargs, "RF_TRACER_PROTOCOL", "http")
|
|
15
|
+
self.capture_arguments = self._get_bool_config(
|
|
16
|
+
"capture_arguments", kwargs, "RF_TRACER_CAPTURE_ARGUMENTS", True
|
|
17
|
+
)
|
|
18
|
+
self.max_arg_length = int(
|
|
19
|
+
self._get_config("max_arg_length", kwargs, "RF_TRACER_MAX_ARG_LENGTH", "200")
|
|
20
|
+
)
|
|
21
|
+
self.capture_logs = self._get_bool_config(
|
|
22
|
+
"capture_logs", kwargs, "RF_TRACER_CAPTURE_LOGS", False
|
|
23
|
+
)
|
|
24
|
+
self.log_level = self._get_config(
|
|
25
|
+
"log_level", kwargs, "RF_TRACER_LOG_LEVEL", "INFO"
|
|
26
|
+
).upper()
|
|
27
|
+
self.max_log_length = int(
|
|
28
|
+
self._get_config("max_log_length", kwargs, "RF_TRACER_MAX_LOG_LENGTH", "500")
|
|
29
|
+
)
|
|
30
|
+
self.sample_rate = float(
|
|
31
|
+
self._get_config("sample_rate", kwargs, "RF_TRACER_SAMPLE_RATE", "1.0")
|
|
32
|
+
)
|
|
33
|
+
self.span_prefix_style = self._get_config(
|
|
34
|
+
"span_prefix_style", kwargs, "RF_TRACER_SPAN_PREFIX_STYLE", "none"
|
|
35
|
+
).lower()
|
|
36
|
+
|
|
37
|
+
def _get_config(self, key, kwargs, env_var, default):
|
|
38
|
+
"""Get configuration value with precedence: kwargs > env > default."""
|
|
39
|
+
return kwargs.get(key, os.environ.get(env_var, default))
|
|
40
|
+
|
|
41
|
+
def _get_bool_config(self, key, kwargs, env_var, default):
|
|
42
|
+
"""Get boolean configuration value."""
|
|
43
|
+
value = self._get_config(key, kwargs, env_var, str(default))
|
|
44
|
+
if isinstance(value, bool):
|
|
45
|
+
return value
|
|
46
|
+
return value.lower() in ("true", "1", "yes")
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def from_listener_args(*args):
|
|
50
|
+
"""Parse listener arguments from Robot Framework.
|
|
51
|
+
|
|
52
|
+
Args can be passed as:
|
|
53
|
+
- Single string: "endpoint=http://localhost:4318,service_name=my-service"
|
|
54
|
+
- Multiple strings: "endpoint=http://localhost:4318", "service_name=my-service"
|
|
55
|
+
"""
|
|
56
|
+
kwargs = {}
|
|
57
|
+
for arg in args:
|
|
58
|
+
if "=" in arg:
|
|
59
|
+
for pair in arg.split(","):
|
|
60
|
+
if "=" in pair:
|
|
61
|
+
key, value = pair.split("=", 1)
|
|
62
|
+
kwargs[key.strip()] = value.strip()
|
|
63
|
+
return TracerConfig(**kwargs)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from opentelemetry import trace
|
|
2
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter as HTTPExporter
|
|
3
|
+
from opentelemetry.sdk.resources import Resource, SERVICE_NAME, SERVICE_VERSION
|
|
4
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
5
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
6
|
+
from opentelemetry.sdk.trace.sampling import TraceIdRatioBased, ParentBased
|
|
7
|
+
from opentelemetry.semconv.resource import ResourceAttributes
|
|
8
|
+
import platform
|
|
9
|
+
import sys
|
|
10
|
+
import robot
|
|
11
|
+
|
|
12
|
+
from .config import TracerConfig
|
|
13
|
+
from .span_builder import SpanBuilder
|
|
14
|
+
|
|
15
|
+
# Try to import gRPC exporter (optional dependency)
|
|
16
|
+
try:
|
|
17
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as GRPCExporter
|
|
18
|
+
GRPC_AVAILABLE = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
GRPC_AVAILABLE = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TracingListener:
|
|
24
|
+
"""Robot Framework Listener v3 for distributed tracing."""
|
|
25
|
+
|
|
26
|
+
ROBOT_LISTENER_API_VERSION = 3
|
|
27
|
+
|
|
28
|
+
def __init__(self, endpoint=None, service_name=None, protocol=None,
|
|
29
|
+
capture_arguments=None, max_arg_length=None, capture_logs=None,
|
|
30
|
+
sample_rate=None, span_prefix_style=None, log_level=None, max_log_length=None):
|
|
31
|
+
"""Initialize the tracing listener.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
endpoint: OTLP endpoint URL
|
|
35
|
+
service_name: Service name for traces
|
|
36
|
+
protocol: Protocol (http or grpc)
|
|
37
|
+
capture_arguments: Whether to capture keyword arguments
|
|
38
|
+
max_arg_length: Maximum length for arguments
|
|
39
|
+
capture_logs: Whether to capture log messages
|
|
40
|
+
sample_rate: Sampling rate (0.0-1.0)
|
|
41
|
+
span_prefix_style: Span prefix style (none, text, emoji)
|
|
42
|
+
log_level: Minimum log level to capture (DEBUG, INFO, WARN, ERROR)
|
|
43
|
+
max_log_length: Maximum length for log messages
|
|
44
|
+
"""
|
|
45
|
+
# Build kwargs dict from provided arguments
|
|
46
|
+
kwargs = {}
|
|
47
|
+
if endpoint is not None:
|
|
48
|
+
kwargs['endpoint'] = endpoint
|
|
49
|
+
if service_name is not None:
|
|
50
|
+
kwargs['service_name'] = service_name
|
|
51
|
+
if protocol is not None:
|
|
52
|
+
kwargs['protocol'] = protocol
|
|
53
|
+
if capture_arguments is not None:
|
|
54
|
+
kwargs['capture_arguments'] = capture_arguments
|
|
55
|
+
if max_arg_length is not None:
|
|
56
|
+
kwargs['max_arg_length'] = max_arg_length
|
|
57
|
+
if capture_logs is not None:
|
|
58
|
+
kwargs['capture_logs'] = capture_logs
|
|
59
|
+
if sample_rate is not None:
|
|
60
|
+
kwargs['sample_rate'] = sample_rate
|
|
61
|
+
if span_prefix_style is not None:
|
|
62
|
+
kwargs['span_prefix_style'] = span_prefix_style
|
|
63
|
+
if log_level is not None:
|
|
64
|
+
kwargs['log_level'] = log_level
|
|
65
|
+
if max_log_length is not None:
|
|
66
|
+
kwargs['max_log_length'] = max_log_length
|
|
67
|
+
|
|
68
|
+
self.config = TracerConfig(**kwargs)
|
|
69
|
+
|
|
70
|
+
# Initialize OpenTelemetry with automatic resource detection
|
|
71
|
+
resource_attrs = {
|
|
72
|
+
SERVICE_NAME: self.config.service_name,
|
|
73
|
+
ResourceAttributes.TELEMETRY_SDK_NAME: "robotframework-tracer",
|
|
74
|
+
ResourceAttributes.TELEMETRY_SDK_LANGUAGE: "python",
|
|
75
|
+
ResourceAttributes.TELEMETRY_SDK_VERSION: "0.1.0",
|
|
76
|
+
"rf.version": robot.version.get_version(),
|
|
77
|
+
"python.version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
|
78
|
+
ResourceAttributes.HOST_NAME: platform.node(),
|
|
79
|
+
ResourceAttributes.OS_TYPE: platform.system(),
|
|
80
|
+
ResourceAttributes.OS_VERSION: platform.release(),
|
|
81
|
+
}
|
|
82
|
+
resource = Resource.create(resource_attrs)
|
|
83
|
+
|
|
84
|
+
# Configure sampling only if sample_rate < 1.0
|
|
85
|
+
if self.config.sample_rate < 1.0:
|
|
86
|
+
sampler = ParentBased(root=TraceIdRatioBased(self.config.sample_rate))
|
|
87
|
+
provider = TracerProvider(resource=resource, sampler=sampler)
|
|
88
|
+
else:
|
|
89
|
+
provider = TracerProvider(resource=resource)
|
|
90
|
+
|
|
91
|
+
# Select exporter based on protocol
|
|
92
|
+
if self.config.protocol == "grpc":
|
|
93
|
+
if not GRPC_AVAILABLE:
|
|
94
|
+
print("Warning: gRPC exporter not available. Install with: pip install opentelemetry-exporter-otlp-proto-grpc")
|
|
95
|
+
print("Falling back to HTTP exporter")
|
|
96
|
+
exporter = HTTPExporter(endpoint=self.config.endpoint)
|
|
97
|
+
else:
|
|
98
|
+
exporter = GRPCExporter(endpoint=self.config.endpoint)
|
|
99
|
+
else:
|
|
100
|
+
exporter = HTTPExporter(endpoint=self.config.endpoint)
|
|
101
|
+
|
|
102
|
+
processor = BatchSpanProcessor(exporter)
|
|
103
|
+
provider.add_span_processor(processor)
|
|
104
|
+
trace.set_tracer_provider(provider)
|
|
105
|
+
|
|
106
|
+
self.tracer = trace.get_tracer(__name__)
|
|
107
|
+
self.span_stack = []
|
|
108
|
+
|
|
109
|
+
def start_suite(self, data, result):
|
|
110
|
+
"""Create root span for suite."""
|
|
111
|
+
try:
|
|
112
|
+
span = SpanBuilder.create_suite_span(self.tracer, data, result, self.config.span_prefix_style)
|
|
113
|
+
self.span_stack.append(span)
|
|
114
|
+
except Exception as e:
|
|
115
|
+
print(f"TracingListener error in start_suite: {e}")
|
|
116
|
+
|
|
117
|
+
def end_suite(self, data, result):
|
|
118
|
+
"""Close suite span."""
|
|
119
|
+
try:
|
|
120
|
+
if self.span_stack:
|
|
121
|
+
span = self.span_stack.pop()
|
|
122
|
+
SpanBuilder.set_span_status(span, result)
|
|
123
|
+
span.end()
|
|
124
|
+
except Exception as e:
|
|
125
|
+
print(f"TracingListener error in end_suite: {e}")
|
|
126
|
+
|
|
127
|
+
def start_test(self, data, result):
|
|
128
|
+
"""Create child span for test case."""
|
|
129
|
+
try:
|
|
130
|
+
parent_context = (
|
|
131
|
+
trace.set_span_in_context(self.span_stack[-1]) if self.span_stack else None
|
|
132
|
+
)
|
|
133
|
+
span = SpanBuilder.create_test_span(self.tracer, data, result, parent_context, self.config.span_prefix_style)
|
|
134
|
+
self.span_stack.append(span)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
print(f"TracingListener error in start_test: {e}")
|
|
137
|
+
|
|
138
|
+
def end_test(self, data, result):
|
|
139
|
+
"""Close test span with verdict."""
|
|
140
|
+
try:
|
|
141
|
+
if self.span_stack:
|
|
142
|
+
span = self.span_stack.pop()
|
|
143
|
+
SpanBuilder.set_span_status(span, result)
|
|
144
|
+
if result.status == "FAIL":
|
|
145
|
+
SpanBuilder.add_error_event(span, result)
|
|
146
|
+
span.end()
|
|
147
|
+
except Exception as e:
|
|
148
|
+
print(f"TracingListener error in end_test: {e}")
|
|
149
|
+
|
|
150
|
+
def start_keyword(self, data, result):
|
|
151
|
+
"""Create child span for keyword/step."""
|
|
152
|
+
try:
|
|
153
|
+
if not self.config.capture_arguments and data.args:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
parent_context = (
|
|
157
|
+
trace.set_span_in_context(self.span_stack[-1]) if self.span_stack else None
|
|
158
|
+
)
|
|
159
|
+
span = SpanBuilder.create_keyword_span(
|
|
160
|
+
self.tracer, data, result, parent_context, self.config.max_arg_length, self.config.span_prefix_style
|
|
161
|
+
)
|
|
162
|
+
self.span_stack.append(span)
|
|
163
|
+
|
|
164
|
+
# Add event for setup/teardown start
|
|
165
|
+
if data.type in ("SETUP", "TEARDOWN"):
|
|
166
|
+
span.add_event(f"{data.type.lower()}.start", {"keyword": data.name})
|
|
167
|
+
except Exception as e:
|
|
168
|
+
print(f"TracingListener error in start_keyword: {e}")
|
|
169
|
+
|
|
170
|
+
def end_keyword(self, data, result):
|
|
171
|
+
"""Close keyword span."""
|
|
172
|
+
try:
|
|
173
|
+
if self.span_stack:
|
|
174
|
+
span = self.span_stack.pop()
|
|
175
|
+
|
|
176
|
+
# Add event for setup/teardown end
|
|
177
|
+
if data.type in ("SETUP", "TEARDOWN"):
|
|
178
|
+
span.add_event(f"{data.type.lower()}.end", {
|
|
179
|
+
"keyword": data.name,
|
|
180
|
+
"status": result.status
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
SpanBuilder.set_span_status(span, result)
|
|
184
|
+
if result.status == "FAIL":
|
|
185
|
+
SpanBuilder.add_error_event(span, result)
|
|
186
|
+
span.end()
|
|
187
|
+
except Exception as e:
|
|
188
|
+
print(f"TracingListener error in end_keyword: {e}")
|
|
189
|
+
|
|
190
|
+
def close(self):
|
|
191
|
+
"""Cleanup on listener close."""
|
|
192
|
+
try:
|
|
193
|
+
while self.span_stack:
|
|
194
|
+
span = self.span_stack.pop()
|
|
195
|
+
span.end()
|
|
196
|
+
trace.get_tracer_provider().force_flush()
|
|
197
|
+
except Exception as e:
|
|
198
|
+
print(f"TracingListener error in close: {e}")
|
|
199
|
+
|
|
200
|
+
def log_message(self, message):
|
|
201
|
+
"""Capture log messages as span events."""
|
|
202
|
+
try:
|
|
203
|
+
if not self.config.capture_logs or not self.span_stack:
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
# Filter by log level
|
|
207
|
+
log_levels = {"TRACE": 0, "DEBUG": 1, "INFO": 2, "WARN": 3, "ERROR": 4, "FAIL": 5}
|
|
208
|
+
min_level = log_levels.get(self.config.log_level, 2)
|
|
209
|
+
msg_level = log_levels.get(message.level, 2)
|
|
210
|
+
|
|
211
|
+
if msg_level < min_level:
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
# Get current span
|
|
215
|
+
current_span = self.span_stack[-1]
|
|
216
|
+
|
|
217
|
+
# Limit message length
|
|
218
|
+
log_text = message.message
|
|
219
|
+
if len(log_text) > self.config.max_log_length:
|
|
220
|
+
log_text = log_text[:self.config.max_log_length] + "..."
|
|
221
|
+
|
|
222
|
+
# Add log as span event (convert timestamp to string)
|
|
223
|
+
event_attrs = {
|
|
224
|
+
"message": log_text,
|
|
225
|
+
"level": message.level,
|
|
226
|
+
}
|
|
227
|
+
if hasattr(message, 'timestamp') and message.timestamp:
|
|
228
|
+
event_attrs["timestamp"] = str(message.timestamp)
|
|
229
|
+
|
|
230
|
+
current_span.add_event(f"log.{message.level.lower()}", event_attrs)
|
|
231
|
+
except RecursionError:
|
|
232
|
+
# Avoid infinite recursion if logging causes more logs
|
|
233
|
+
pass
|
|
234
|
+
except Exception:
|
|
235
|
+
# Silently ignore errors in log capture to avoid breaking tests
|
|
236
|
+
pass
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from opentelemetry import trace, baggage
|
|
2
|
+
from opentelemetry.trace import Status, StatusCode
|
|
3
|
+
import robot
|
|
4
|
+
|
|
5
|
+
from .attributes import AttributeExtractor, RFAttributes
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SpanBuilder:
|
|
9
|
+
"""Build OpenTelemetry spans from Robot Framework objects."""
|
|
10
|
+
|
|
11
|
+
# Prefix mappings
|
|
12
|
+
TEXT_PREFIXES = {
|
|
13
|
+
"SUITE": "[SUITE]",
|
|
14
|
+
"TEST": "[TEST CASE]",
|
|
15
|
+
"KEYWORD": "[TEST STEP]",
|
|
16
|
+
"SETUP": "[SETUP]",
|
|
17
|
+
"TEARDOWN": "[TEARDOWN]",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
EMOJI_PREFIXES = {
|
|
21
|
+
"SUITE": "📦",
|
|
22
|
+
"TEST": "🧪",
|
|
23
|
+
"KEYWORD": "👟",
|
|
24
|
+
"SETUP": "🔧",
|
|
25
|
+
"TEARDOWN": "🧹",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def _add_prefix(name, span_type, prefix_style):
|
|
30
|
+
"""Add prefix to span name based on style."""
|
|
31
|
+
if prefix_style == "none" or not prefix_style:
|
|
32
|
+
return name
|
|
33
|
+
elif prefix_style == "text":
|
|
34
|
+
prefix = SpanBuilder.TEXT_PREFIXES.get(span_type, "")
|
|
35
|
+
return f"{prefix} {name}" if prefix else name
|
|
36
|
+
elif prefix_style == "emoji":
|
|
37
|
+
prefix = SpanBuilder.EMOJI_PREFIXES.get(span_type, "")
|
|
38
|
+
return f"{prefix} {name}" if prefix else name
|
|
39
|
+
return name
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def create_suite_span(tracer, data, result, prefix_style="none"):
|
|
43
|
+
"""Create root span for test suite."""
|
|
44
|
+
attrs = AttributeExtractor.from_suite(data, result)
|
|
45
|
+
name = SpanBuilder._add_prefix(data.name, "SUITE", prefix_style)
|
|
46
|
+
|
|
47
|
+
# Create context with baggage
|
|
48
|
+
ctx = baggage.set_baggage("rf.suite.id", result.id)
|
|
49
|
+
ctx = baggage.set_baggage("rf.version", robot.version.get_version(), ctx)
|
|
50
|
+
|
|
51
|
+
# Add suite metadata to baggage (limit to avoid too much data)
|
|
52
|
+
if hasattr(data, 'metadata') and data.metadata:
|
|
53
|
+
for key, value in list(data.metadata.items())[:5]: # Limit to 5 metadata items
|
|
54
|
+
ctx = baggage.set_baggage(f"rf.suite.metadata.{key}", str(value), ctx)
|
|
55
|
+
|
|
56
|
+
span = tracer.start_span(name, context=ctx, kind=trace.SpanKind.INTERNAL, attributes=attrs)
|
|
57
|
+
return span
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def create_test_span(tracer, data, result, parent_context, prefix_style="none"):
|
|
61
|
+
"""Create child span for test case."""
|
|
62
|
+
attrs = AttributeExtractor.from_test(data, result)
|
|
63
|
+
name = SpanBuilder._add_prefix(data.name, "TEST", prefix_style)
|
|
64
|
+
span = tracer.start_span(
|
|
65
|
+
name, context=parent_context, kind=trace.SpanKind.INTERNAL, attributes=attrs
|
|
66
|
+
)
|
|
67
|
+
return span
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def create_keyword_span(tracer, data, result, parent_context, max_arg_length=200, prefix_style="none"):
|
|
71
|
+
"""Create child span for keyword."""
|
|
72
|
+
attrs = AttributeExtractor.from_keyword(data, result, max_arg_length)
|
|
73
|
+
|
|
74
|
+
# Build keyword name with arguments (like RF test step line)
|
|
75
|
+
kw_name = data.name
|
|
76
|
+
if data.args:
|
|
77
|
+
# Join arguments with comma-space
|
|
78
|
+
args_str = ", ".join(str(arg) for arg in data.args)
|
|
79
|
+
# Limit total length to avoid extremely long span names
|
|
80
|
+
if len(args_str) > 100:
|
|
81
|
+
args_str = args_str[:100] + "..."
|
|
82
|
+
kw_name = f"{data.name} {args_str}"
|
|
83
|
+
|
|
84
|
+
# Determine span type for prefix
|
|
85
|
+
if data.type in ("SETUP", "TEARDOWN"):
|
|
86
|
+
span_type = data.type
|
|
87
|
+
else:
|
|
88
|
+
span_type = "KEYWORD"
|
|
89
|
+
|
|
90
|
+
# Add prefix based on style
|
|
91
|
+
kw_name = SpanBuilder._add_prefix(kw_name, span_type, prefix_style)
|
|
92
|
+
|
|
93
|
+
span = tracer.start_span(
|
|
94
|
+
kw_name, context=parent_context, kind=trace.SpanKind.INTERNAL, attributes=attrs
|
|
95
|
+
)
|
|
96
|
+
return span
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def set_span_status(span, result):
|
|
100
|
+
"""Set span status based on RF result."""
|
|
101
|
+
span.set_attribute(RFAttributes.STATUS, result.status)
|
|
102
|
+
span.set_attribute(RFAttributes.ELAPSED_TIME, result.elapsedtime / 1000.0)
|
|
103
|
+
|
|
104
|
+
if result.status == "FAIL":
|
|
105
|
+
span.set_status(Status(StatusCode.ERROR, result.message))
|
|
106
|
+
else:
|
|
107
|
+
span.set_status(Status(StatusCode.OK))
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def add_error_event(span, result):
|
|
111
|
+
"""Add error event with exception details."""
|
|
112
|
+
if result.status == "FAIL" and result.message:
|
|
113
|
+
# Create detailed error event
|
|
114
|
+
event_attrs = {
|
|
115
|
+
"message": result.message,
|
|
116
|
+
"rf.status": "FAIL",
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# Try to extract exception type if available
|
|
120
|
+
if hasattr(result, 'error') and result.error:
|
|
121
|
+
event_attrs["exception.type"] = type(result.error).__name__
|
|
122
|
+
event_attrs["exception.message"] = str(result.error)
|
|
123
|
+
|
|
124
|
+
# Add timestamp
|
|
125
|
+
if hasattr(result, 'endtime') and result.endtime:
|
|
126
|
+
event_attrs["timestamp"] = result.endtime
|
|
127
|
+
|
|
128
|
+
span.add_event("test.failed", event_attrs)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: robotframework-tracer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenTelemetry distributed tracing for Robot Framework
|
|
5
|
+
Author: Robot Framework Tracer Contributors
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/yourusername/robotframework-tracer
|
|
8
|
+
Project-URL: Documentation, https://github.com/yourusername/robotframework-tracer/blob/main/README.md
|
|
9
|
+
Project-URL: Repository, https://github.com/yourusername/robotframework-tracer
|
|
10
|
+
Project-URL: Issues, https://github.com/yourusername/robotframework-tracer/issues
|
|
11
|
+
Keywords: robotframework,testing,tracing,opentelemetry,observability
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Topic :: Software Development :: Testing
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Framework :: Robot Framework
|
|
23
|
+
Requires-Python: >=3.8
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
Requires-Dist: robotframework>=6.0
|
|
26
|
+
Requires-Dist: opentelemetry-api>=1.20.0
|
|
27
|
+
Requires-Dist: opentelemetry-sdk>=1.20.0
|
|
28
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.20.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
32
|
+
Requires-Dist: black>=23.0; extra == "dev"
|
|
33
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
34
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
35
|
+
Provides-Extra: grpc
|
|
36
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.20.0; extra == "grpc"
|
|
37
|
+
|
|
38
|
+
# Robot Framework Tracer
|
|
39
|
+
|
|
40
|
+
OpenTelemetry distributed tracing integration for Robot Framework test execution.
|
|
41
|
+
|
|
42
|
+
## What is this?
|
|
43
|
+
|
|
44
|
+
`robotframework-tracer` is a Robot Framework listener plugin that automatically creates distributed traces for your test execution using OpenTelemetry. It captures the complete test hierarchy (suites → tests → keywords) as spans and exports them to any OpenTelemetry-compatible backend like Jaeger, Grafana Tempo, or Zipkin.
|
|
45
|
+
|
|
46
|
+
This enables you to:
|
|
47
|
+
- **Visualize test execution flow** with detailed timing information
|
|
48
|
+
- **Debug test failures** by examining the complete execution trace
|
|
49
|
+
- **Analyze performance** and identify slow keywords or tests
|
|
50
|
+
- **Correlate tests with application traces** in distributed systems
|
|
51
|
+
- **Monitor test execution** across CI/CD pipelines
|
|
52
|
+
|
|
53
|
+

|
|
54
|
+
|
|
55
|
+
## How it works
|
|
56
|
+
|
|
57
|
+
The tracer implements the Robot Framework Listener v3 API and creates OpenTelemetry spans for each test execution phase:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
Suite Span (root)
|
|
61
|
+
├── Test Case Span
|
|
62
|
+
│ ├── Keyword Span
|
|
63
|
+
│ │ └── Nested Keyword Span
|
|
64
|
+
│ └── Keyword Span
|
|
65
|
+
└── Test Case Span
|
|
66
|
+
└── Keyword Span
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Each span includes rich metadata: test names, tags, status (PASS/FAIL), timing, arguments, and error details.
|
|
70
|
+
|
|
71
|
+
## Installation
|
|
72
|
+
|
|
73
|
+
### From PyPI (when released)
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install robotframework-tracer
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### From Source (Development)
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# Clone the repository
|
|
83
|
+
git clone <repository-url>
|
|
84
|
+
cd robotframework-tracer
|
|
85
|
+
|
|
86
|
+
# Create and activate virtual environment
|
|
87
|
+
python3 -m venv venv
|
|
88
|
+
source venv/bin/activate # On Windows: venv\Scripts\activate
|
|
89
|
+
|
|
90
|
+
# Install in development mode
|
|
91
|
+
pip install -e ".[dev]"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for detailed development setup instructions.
|
|
95
|
+
|
|
96
|
+
## Quick Start
|
|
97
|
+
|
|
98
|
+
### 1. Start a tracing backend (Jaeger example)
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
docker run -d --name jaeger \
|
|
102
|
+
-p 16686:16686 \
|
|
103
|
+
-p 4318:4318 \
|
|
104
|
+
jaegertracing/all-in-one:latest
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 2. Run your tests with the listener
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
robot --listener robotframework_tracer.TracingListener tests/
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### 3. View traces
|
|
114
|
+
|
|
115
|
+
Open http://localhost:16686 in your browser to see your test traces in Jaeger UI.
|
|
116
|
+
|
|
117
|
+
## Configuration
|
|
118
|
+
|
|
119
|
+
### Basic usage
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
robot --listener robotframework_tracer.TracingListener tests/
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Custom endpoint
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
robot --listener robotframework_tracer.TracingListener:endpoint=http://jaeger:4318/v1/traces tests/
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Custom service name
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
robot --listener "robotframework_tracer.TracingListener:endpoint=http://jaeger:4318/v1/traces,service_name=my-tests" tests/
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### All configuration options
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
robot --listener "robotframework_tracer.TracingListener:\
|
|
141
|
+
endpoint=http://localhost:4318/v1/traces,\
|
|
142
|
+
service_name=robot-tests,\
|
|
143
|
+
protocol=http,\
|
|
144
|
+
capture_arguments=true,\
|
|
145
|
+
max_arg_length=200" tests/
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Environment variables
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
|
152
|
+
export OTEL_SERVICE_NAME=robot-framework-tests
|
|
153
|
+
robot --listener robotframework_tracer.TracingListener tests/
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Configuration Options
|
|
157
|
+
|
|
158
|
+
| Option | Default | Description |
|
|
159
|
+
|--------|---------|-------------|
|
|
160
|
+
| `endpoint` | `http://localhost:4318/v1/traces` | OTLP endpoint URL |
|
|
161
|
+
| `service_name` | `rf` | Service name in traces |
|
|
162
|
+
| `protocol` | `http` | Protocol: `http` or `grpc` |
|
|
163
|
+
| `span_prefix_style` | `none` | Span prefix style: `none`, `text`, `emoji` |
|
|
164
|
+
| `capture_arguments` | `true` | Capture keyword arguments |
|
|
165
|
+
| `max_arg_length` | `200` | Max length for arguments |
|
|
166
|
+
| `capture_logs` | `false` | Capture log messages as events |
|
|
167
|
+
| `log_level` | `INFO` | Minimum log level (DEBUG, INFO, WARN, ERROR) |
|
|
168
|
+
| `max_log_length` | `500` | Max length for log messages |
|
|
169
|
+
| `sample_rate` | `1.0` | Sampling rate (0.0-1.0, 1.0 = no sampling) |
|
|
170
|
+
|
|
171
|
+
## Span Attributes
|
|
172
|
+
|
|
173
|
+
Each span includes relevant Robot Framework metadata:
|
|
174
|
+
|
|
175
|
+
**Suite spans:**
|
|
176
|
+
- `rf.suite.name` - Suite name
|
|
177
|
+
- `rf.suite.source` - Suite file path
|
|
178
|
+
- `rf.suite.id` - Suite ID
|
|
179
|
+
- `rf.version` - Robot Framework version
|
|
180
|
+
|
|
181
|
+
**Test spans:**
|
|
182
|
+
- `rf.test.name` - Test case name
|
|
183
|
+
- `rf.test.id` - Test ID
|
|
184
|
+
- `rf.test.tags` - Test tags
|
|
185
|
+
- `rf.status` - PASS/FAIL/SKIP
|
|
186
|
+
- `rf.elapsed_time` - Execution time
|
|
187
|
+
|
|
188
|
+
**Keyword spans:**
|
|
189
|
+
- `rf.keyword.name` - Keyword name
|
|
190
|
+
- `rf.keyword.type` - SETUP/TEARDOWN/KEYWORD
|
|
191
|
+
- `rf.keyword.library` - Library name
|
|
192
|
+
- `rf.keyword.args` - Arguments (if enabled)
|
|
193
|
+
- `rf.status` - PASS/FAIL
|
|
194
|
+
|
|
195
|
+
## Supported Backends
|
|
196
|
+
|
|
197
|
+
Works with any OpenTelemetry-compatible backend:
|
|
198
|
+
- **Jaeger** - Open source tracing platform
|
|
199
|
+
- **Grafana Tempo** - High-scale distributed tracing
|
|
200
|
+
- **Zipkin** - Distributed tracing system
|
|
201
|
+
- **AWS X-Ray** - AWS distributed tracing
|
|
202
|
+
- **Honeycomb** - Observability platform
|
|
203
|
+
- **Datadog** - Monitoring and analytics
|
|
204
|
+
|
|
205
|
+
See [docs/backends.md](docs/backends.md) for backend-specific setup guides.
|
|
206
|
+
|
|
207
|
+
## Requirements
|
|
208
|
+
|
|
209
|
+
- Python 3.8+
|
|
210
|
+
- Robot Framework 6.0+
|
|
211
|
+
- OpenTelemetry SDK
|
|
212
|
+
|
|
213
|
+
## Documentation
|
|
214
|
+
|
|
215
|
+
- [Architecture](docs/ARCHITECTURE.md) - Design and architecture details
|
|
216
|
+
- [Implementation Plan](docs/IMPLEMENTATION_PLAN.md) - Development roadmap
|
|
217
|
+
- [Configuration Guide](docs/configuration.md) - Detailed configuration reference
|
|
218
|
+
- [Attribute Reference](docs/attributes.md) - Complete attribute documentation
|
|
219
|
+
- [Backend Setup](docs/backends.md) - Backend-specific guides
|
|
220
|
+
|
|
221
|
+
## Examples
|
|
222
|
+
|
|
223
|
+
See the [examples/](examples/) directory for complete examples:
|
|
224
|
+
- Basic usage with Jaeger
|
|
225
|
+
- Advanced configuration
|
|
226
|
+
- CI/CD integration
|
|
227
|
+
- Multiple backend setups
|
|
228
|
+
|
|
229
|
+
## Contributing
|
|
230
|
+
|
|
231
|
+
Contributions are welcome! Please see [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) for guidelines.
|
|
232
|
+
|
|
233
|
+
## License
|
|
234
|
+
|
|
235
|
+
Apache License 2.0 - See [docs/LICENSE](docs/LICENSE) for details.
|
|
236
|
+
|
|
237
|
+
## Status
|
|
238
|
+
|
|
239
|
+
**Current Version:** v0.1.0
|
|
240
|
+
**Status:** Production-ready MVP
|
|
241
|
+
|
|
242
|
+
Core functionality is complete and tested. See [docs/CHANGELOG.md](docs/CHANGELOG.md) for version history and [docs/IMPLEMENTATION_PLAN.md](docs/IMPLEMENTATION_PLAN.md) for the development roadmap.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
robotframework_tracer/__init__.py,sha256=cZh3xnNaYhRisrqqWH5Gdt4zsgpbh1mUALJiUtGNG8M,204
|
|
2
|
+
robotframework_tracer/attributes.py,sha256=7lpqy-7maQma01tfG3TzUNLIseQPzARCuYPFiy1K3ao,3854
|
|
3
|
+
robotframework_tracer/config.py,sha256=qCckfLNvwRUrJ8EolG47zJDmZG-9175V7_1gR4ypNZc,2552
|
|
4
|
+
robotframework_tracer/listener.py,sha256=8FMv2BTFo3Q471dFVCoS0YfLrHkeBSPsH6EKG75ccqQ,9795
|
|
5
|
+
robotframework_tracer/span_builder.py,sha256=NKo0AOS-ZxYY_Z6Oypfbg33CAt2EKt6016Vh8woDtfo,4914
|
|
6
|
+
robotframework_tracer/version.py,sha256=rnObPjuBcEStqSO0S6gsdS_ot8ITOQjVj_-P1LUUYpg,22
|
|
7
|
+
robotframework_tracer-0.1.0.dist-info/METADATA,sha256=C60xEMX1xrxLxefDlGggCCzbwd1Nbt_mJI5mXQdyLOY,7654
|
|
8
|
+
robotframework_tracer-0.1.0.dist-info/WHEEL,sha256=iAkIy5fosb7FzIOwONchHf19Qu7_1wCWyFNR5gu9nU0,91
|
|
9
|
+
robotframework_tracer-0.1.0.dist-info/top_level.txt,sha256=G1sMKH-8SM_CdJe0Wm6wa_rg1uo62jfhft_UfaxZ05I,22
|
|
10
|
+
robotframework_tracer-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
robotframework_tracer
|