lmnr 0.4.58__tar.gz → 0.4.60__tar.gz
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.
- {lmnr-0.4.58 → lmnr-0.4.60}/PKG-INFO +1 -1
- {lmnr-0.4.58 → lmnr-0.4.60}/pyproject.toml +1 -1
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/config/__init__.py +3 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/decorators/base.py +45 -27
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/tracing/attributes.py +3 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/tracing/tracing.py +25 -5
- lmnr-0.4.60/src/lmnr/sdk/__init__.py +0 -0
- lmnr-0.4.60/src/lmnr/sdk/browser/playwright_patch.py +349 -0
- lmnr-0.4.60/src/lmnr/sdk/browser/rrweb/rrweb.min.js +18 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/sdk/decorators.py +16 -3
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/sdk/evaluations.py +8 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/sdk/laminar.py +49 -13
- lmnr-0.4.60/src/lmnr/version.py +5 -0
- lmnr-0.4.58/src/lmnr/openllmetry_sdk/version.py +0 -1
- lmnr-0.4.58/src/lmnr/sdk/browser/playwright_patch.py +0 -248
- {lmnr-0.4.58 → lmnr-0.4.60}/LICENSE +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/README.md +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/__init__.py +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/cli.py +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/.flake8 +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/.python-version +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/__init__.py +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/decorators/__init__.py +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/instruments.py +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/tracing/__init__.py +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/tracing/content_allow_list.py +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/tracing/context_manager.py +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/utils/__init__.py +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/utils/in_memory_span_exporter.py +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/utils/json_encoder.py +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/openllmetry_sdk/utils/package_check.py +0 -0
- /lmnr-0.4.58/src/lmnr/sdk/__init__.py → /lmnr-0.4.60/src/lmnr/py.typed +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/sdk/browser/__init__.py +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/sdk/datasets.py +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/sdk/eval_control.py +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/sdk/log.py +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/sdk/types.py +0 -0
- {lmnr-0.4.58 → lmnr-0.4.60}/src/lmnr/sdk/utils.py +0 -0
@@ -4,7 +4,7 @@ import logging
|
|
4
4
|
import os
|
5
5
|
import pydantic
|
6
6
|
import types
|
7
|
-
from typing import Any, Optional
|
7
|
+
from typing import Any, Literal, Optional, Union
|
8
8
|
|
9
9
|
from opentelemetry import trace
|
10
10
|
from opentelemetry import context as context_api
|
@@ -12,9 +12,10 @@ from opentelemetry.trace import Span
|
|
12
12
|
|
13
13
|
from lmnr.sdk.utils import get_input_from_func_args, is_method
|
14
14
|
from lmnr.openllmetry_sdk.tracing import get_tracer
|
15
|
-
from lmnr.openllmetry_sdk.tracing.attributes import SPAN_INPUT, SPAN_OUTPUT
|
15
|
+
from lmnr.openllmetry_sdk.tracing.attributes import SPAN_INPUT, SPAN_OUTPUT, SPAN_TYPE
|
16
16
|
from lmnr.openllmetry_sdk.tracing.tracing import TracerWrapper
|
17
17
|
from lmnr.openllmetry_sdk.utils.json_encoder import JSONEncoder
|
18
|
+
from lmnr.openllmetry_sdk.config import MAX_MANUAL_SPAN_PAYLOAD_SIZE
|
18
19
|
|
19
20
|
|
20
21
|
class CustomJSONEncoder(JSONEncoder):
|
@@ -38,6 +39,9 @@ def json_dumps(data: dict) -> str:
|
|
38
39
|
|
39
40
|
def entity_method(
|
40
41
|
name: Optional[str] = None,
|
42
|
+
ignore_input: bool = False,
|
43
|
+
ignore_output: bool = False,
|
44
|
+
span_type: Union[Literal["DEFAULT"], Literal["LLM"], Literal["TOOL"]] = "DEFAULT",
|
41
45
|
):
|
42
46
|
def decorate(fn):
|
43
47
|
@wraps(fn)
|
@@ -48,21 +52,22 @@ def entity_method(
|
|
48
52
|
span_name = name or fn.__name__
|
49
53
|
|
50
54
|
with get_tracer() as tracer:
|
51
|
-
span = tracer.start_span(span_name)
|
55
|
+
span = tracer.start_span(span_name, attributes={SPAN_TYPE: span_type})
|
52
56
|
|
53
57
|
ctx = trace.set_span_in_context(span, context_api.get_current())
|
54
58
|
ctx_token = context_api.attach(ctx)
|
55
59
|
|
56
60
|
try:
|
57
|
-
if _should_send_prompts():
|
58
|
-
|
59
|
-
|
60
|
-
json_dumps(
|
61
|
-
get_input_from_func_args(
|
62
|
-
fn, is_method(fn), args, kwargs
|
63
|
-
)
|
64
|
-
),
|
61
|
+
if _should_send_prompts() and not ignore_input:
|
62
|
+
inp = json_dumps(
|
63
|
+
get_input_from_func_args(fn, is_method(fn), args, kwargs)
|
65
64
|
)
|
65
|
+
if len(inp) > MAX_MANUAL_SPAN_PAYLOAD_SIZE:
|
66
|
+
span.set_attribute(
|
67
|
+
SPAN_INPUT, "Laminar: input too large to record"
|
68
|
+
)
|
69
|
+
else:
|
70
|
+
span.set_attribute(SPAN_INPUT, inp)
|
66
71
|
except TypeError:
|
67
72
|
pass
|
68
73
|
|
@@ -78,11 +83,14 @@ def entity_method(
|
|
78
83
|
return _handle_generator(span, res)
|
79
84
|
|
80
85
|
try:
|
81
|
-
if _should_send_prompts():
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
+
if _should_send_prompts() and not ignore_output:
|
87
|
+
output = json_dumps(res)
|
88
|
+
if len(output) > MAX_MANUAL_SPAN_PAYLOAD_SIZE:
|
89
|
+
span.set_attribute(
|
90
|
+
SPAN_OUTPUT, "Laminar: output too large to record"
|
91
|
+
)
|
92
|
+
else:
|
93
|
+
span.set_attribute(SPAN_OUTPUT, output)
|
86
94
|
except TypeError:
|
87
95
|
pass
|
88
96
|
|
@@ -99,6 +107,9 @@ def entity_method(
|
|
99
107
|
# Async Decorators
|
100
108
|
def aentity_method(
|
101
109
|
name: Optional[str] = None,
|
110
|
+
ignore_input: bool = False,
|
111
|
+
ignore_output: bool = False,
|
112
|
+
span_type: Union[Literal["DEFAULT"], Literal["LLM"], Literal["TOOL"]] = "DEFAULT",
|
102
113
|
):
|
103
114
|
def decorate(fn):
|
104
115
|
@wraps(fn)
|
@@ -109,21 +120,22 @@ def aentity_method(
|
|
109
120
|
span_name = name or fn.__name__
|
110
121
|
|
111
122
|
with get_tracer() as tracer:
|
112
|
-
span = tracer.start_span(span_name)
|
123
|
+
span = tracer.start_span(span_name, attributes={SPAN_TYPE: span_type})
|
113
124
|
|
114
125
|
ctx = trace.set_span_in_context(span, context_api.get_current())
|
115
126
|
ctx_token = context_api.attach(ctx)
|
116
127
|
|
117
128
|
try:
|
118
|
-
if _should_send_prompts():
|
119
|
-
|
120
|
-
|
121
|
-
json_dumps(
|
122
|
-
get_input_from_func_args(
|
123
|
-
fn, is_method(fn), args, kwargs
|
124
|
-
)
|
125
|
-
),
|
129
|
+
if _should_send_prompts() and not ignore_input:
|
130
|
+
inp = json_dumps(
|
131
|
+
get_input_from_func_args(fn, is_method(fn), args, kwargs)
|
126
132
|
)
|
133
|
+
if len(inp) > MAX_MANUAL_SPAN_PAYLOAD_SIZE:
|
134
|
+
span.set_attribute(
|
135
|
+
SPAN_INPUT, "Laminar: input too large to record"
|
136
|
+
)
|
137
|
+
else:
|
138
|
+
span.set_attribute(SPAN_INPUT, inp)
|
127
139
|
except TypeError:
|
128
140
|
pass
|
129
141
|
|
@@ -139,8 +151,14 @@ def aentity_method(
|
|
139
151
|
return await _ahandle_generator(span, ctx_token, res)
|
140
152
|
|
141
153
|
try:
|
142
|
-
if _should_send_prompts():
|
143
|
-
|
154
|
+
if _should_send_prompts() and not ignore_output:
|
155
|
+
output = json_dumps(res)
|
156
|
+
if len(output) > MAX_MANUAL_SPAN_PAYLOAD_SIZE:
|
157
|
+
span.set_attribute(
|
158
|
+
SPAN_OUTPUT, "Laminar: output too large to record"
|
159
|
+
)
|
160
|
+
else:
|
161
|
+
span.set_attribute(SPAN_OUTPUT, output)
|
144
162
|
except TypeError:
|
145
163
|
pass
|
146
164
|
|
@@ -5,7 +5,10 @@ SPAN_INPUT = "lmnr.span.input"
|
|
5
5
|
SPAN_OUTPUT = "lmnr.span.output"
|
6
6
|
SPAN_TYPE = "lmnr.span.type"
|
7
7
|
SPAN_PATH = "lmnr.span.path"
|
8
|
+
SPAN_IDS_PATH = "lmnr.span.ids_path"
|
8
9
|
SPAN_INSTRUMENTATION_SOURCE = "lmnr.span.instrumentation_source"
|
10
|
+
SPAN_SDK_VERSION = "lmnr.span.sdk_version"
|
11
|
+
SPAN_LANGUAGE_VERSION = "lmnr.span.language_version"
|
9
12
|
OVERRIDE_PARENT_SPAN = "lmnr.internal.override_parent_span"
|
10
13
|
|
11
14
|
ASSOCIATION_PROPERTIES = "lmnr.association.properties"
|
@@ -1,7 +1,7 @@
|
|
1
1
|
import atexit
|
2
2
|
import copy
|
3
3
|
import logging
|
4
|
-
|
4
|
+
import uuid
|
5
5
|
|
6
6
|
from contextvars import Context
|
7
7
|
from lmnr.sdk.log import VerboseColorfulFormatter
|
@@ -9,7 +9,10 @@ from lmnr.openllmetry_sdk.instruments import Instruments
|
|
9
9
|
from lmnr.sdk.browser import init_browser_tracing
|
10
10
|
from lmnr.openllmetry_sdk.tracing.attributes import (
|
11
11
|
ASSOCIATION_PROPERTIES,
|
12
|
+
SPAN_IDS_PATH,
|
12
13
|
SPAN_INSTRUMENTATION_SOURCE,
|
14
|
+
SPAN_SDK_VERSION,
|
15
|
+
SPAN_LANGUAGE_VERSION,
|
13
16
|
SPAN_PATH,
|
14
17
|
TRACING_LEVEL,
|
15
18
|
)
|
@@ -23,6 +26,7 @@ from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
|
23
26
|
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
24
27
|
OTLPSpanExporter as GRPCExporter,
|
25
28
|
)
|
29
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import Compression
|
26
30
|
from opentelemetry.instrumentation.threading import ThreadingInstrumentor
|
27
31
|
from opentelemetry.context import get_value, attach, get_current, set_value, Context
|
28
32
|
from opentelemetry.propagate import set_global_textmap
|
@@ -38,6 +42,8 @@ from opentelemetry.trace import get_tracer_provider, ProxyTracerProvider
|
|
38
42
|
|
39
43
|
from typing import Dict, Optional, Set
|
40
44
|
|
45
|
+
from lmnr.version import SDK_VERSION, PYTHON_VERSION
|
46
|
+
|
41
47
|
module_logger = logging.getLogger(__name__)
|
42
48
|
console_log_handler = logging.StreamHandler()
|
43
49
|
console_log_handler.setFormatter(VerboseColorfulFormatter())
|
@@ -72,6 +78,7 @@ class TracerWrapper(object):
|
|
72
78
|
__tracer_provider: TracerProvider = None
|
73
79
|
__logger: logging.Logger = None
|
74
80
|
__span_id_to_path: dict[int, list[str]] = {}
|
81
|
+
__span_id_lists: dict[int, list[str]] = {}
|
75
82
|
|
76
83
|
def __new__(
|
77
84
|
cls,
|
@@ -132,9 +139,9 @@ class TracerWrapper(object):
|
|
132
139
|
)
|
133
140
|
|
134
141
|
if not instrument_set:
|
135
|
-
cls.__logger.
|
136
|
-
"No
|
137
|
-
"
|
142
|
+
cls.__logger.info(
|
143
|
+
"No instruments set through Laminar. "
|
144
|
+
"Only enabling basic OpenTelemetry tracing."
|
138
145
|
)
|
139
146
|
|
140
147
|
obj.__content_allow_list = ContentAllowList()
|
@@ -162,12 +169,22 @@ class TracerWrapper(object):
|
|
162
169
|
parent_span_path = span_path_in_context or (
|
163
170
|
self.__span_id_to_path.get(span.parent.span_id) if span.parent else None
|
164
171
|
)
|
172
|
+
parent_span_ids_path = (
|
173
|
+
self.__span_id_lists.get(span.parent.span_id, []) if span.parent else []
|
174
|
+
)
|
165
175
|
span_path = parent_span_path + [span.name] if parent_span_path else [span.name]
|
176
|
+
span_ids_path = parent_span_ids_path + [
|
177
|
+
str(uuid.UUID(int=span.get_span_context().span_id))
|
178
|
+
]
|
166
179
|
span.set_attribute(SPAN_PATH, span_path)
|
180
|
+
span.set_attribute(SPAN_IDS_PATH, span_ids_path)
|
167
181
|
set_value("span_path", span_path, get_current())
|
168
182
|
self.__span_id_to_path[span.get_span_context().span_id] = span_path
|
183
|
+
self.__span_id_lists[span.get_span_context().span_id] = span_ids_path
|
169
184
|
|
170
185
|
span.set_attribute(SPAN_INSTRUMENTATION_SOURCE, "python")
|
186
|
+
span.set_attribute(SPAN_SDK_VERSION, SDK_VERSION)
|
187
|
+
span.set_attribute(SPAN_LANGUAGE_VERSION, f"python@{PYTHON_VERSION}")
|
171
188
|
|
172
189
|
association_properties = get_value("association_properties")
|
173
190
|
if association_properties is not None:
|
@@ -203,6 +220,7 @@ class TracerWrapper(object):
|
|
203
220
|
def clear(cls):
|
204
221
|
# Any state cleanup. Now used in between tests
|
205
222
|
cls.__span_id_to_path = {}
|
223
|
+
cls.__span_id_lists = {}
|
206
224
|
|
207
225
|
def flush(self):
|
208
226
|
self.__spans_processor.force_flush()
|
@@ -268,7 +286,9 @@ def init_spans_exporter(api_endpoint: str, headers: Dict[str, str]) -> SpanExpor
|
|
268
286
|
if "http" in api_endpoint.lower() or "https" in api_endpoint.lower():
|
269
287
|
return HTTPExporter(endpoint=f"{api_endpoint}/v1/traces", headers=headers)
|
270
288
|
else:
|
271
|
-
return GRPCExporter(
|
289
|
+
return GRPCExporter(
|
290
|
+
endpoint=f"{api_endpoint}", headers=headers, compression=Compression.Gzip
|
291
|
+
)
|
272
292
|
|
273
293
|
|
274
294
|
def init_tracer_provider(resource: Resource) -> TracerProvider:
|
File without changes
|
@@ -0,0 +1,349 @@
|
|
1
|
+
import opentelemetry
|
2
|
+
import uuid
|
3
|
+
import asyncio
|
4
|
+
import logging
|
5
|
+
import time
|
6
|
+
import os
|
7
|
+
|
8
|
+
logger = logging.getLogger(__name__)
|
9
|
+
|
10
|
+
try:
|
11
|
+
from playwright.async_api import BrowserContext, Page
|
12
|
+
from playwright.sync_api import (
|
13
|
+
BrowserContext as SyncBrowserContext,
|
14
|
+
Page as SyncPage,
|
15
|
+
)
|
16
|
+
except ImportError as e:
|
17
|
+
raise ImportError(
|
18
|
+
f"Attempted to import {__file__}, but it is designed "
|
19
|
+
"to patch Playwright, which is not installed. Use `pip install playwright` "
|
20
|
+
"to install Playwright or remove this import."
|
21
|
+
) from e
|
22
|
+
|
23
|
+
_original_new_page = None
|
24
|
+
_original_new_page_async = None
|
25
|
+
|
26
|
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
27
|
+
with open(os.path.join(current_dir, "rrweb", "rrweb.min.js"), "r") as f:
|
28
|
+
RRWEB_CONTENT = f"() => {{ {f.read()} }}"
|
29
|
+
|
30
|
+
INJECT_PLACEHOLDER = """
|
31
|
+
([baseUrl, projectApiKey]) => {
|
32
|
+
const serverUrl = `${baseUrl}/v1/browser-sessions/events`;
|
33
|
+
const FLUSH_INTERVAL = 1000;
|
34
|
+
const HEARTBEAT_INTERVAL = 1000;
|
35
|
+
|
36
|
+
window.lmnrRrwebEventsBatch = [];
|
37
|
+
|
38
|
+
window.lmnrSendRrwebEventsBatch = async () => {
|
39
|
+
if (window.lmnrRrwebEventsBatch.length === 0) return;
|
40
|
+
|
41
|
+
const eventsPayload = {
|
42
|
+
sessionId: window.lmnrRrwebSessionId,
|
43
|
+
traceId: window.lmnrTraceId,
|
44
|
+
events: window.lmnrRrwebEventsBatch
|
45
|
+
};
|
46
|
+
|
47
|
+
try {
|
48
|
+
const jsonString = JSON.stringify(eventsPayload);
|
49
|
+
const uint8Array = new TextEncoder().encode(jsonString);
|
50
|
+
|
51
|
+
const cs = new CompressionStream('gzip');
|
52
|
+
const compressedStream = await new Response(
|
53
|
+
new Response(uint8Array).body.pipeThrough(cs)
|
54
|
+
).arrayBuffer();
|
55
|
+
|
56
|
+
const compressedArray = new Uint8Array(compressedStream);
|
57
|
+
|
58
|
+
const blob = new Blob([compressedArray], { type: 'application/octet-stream' });
|
59
|
+
|
60
|
+
const response = await fetch(serverUrl, {
|
61
|
+
method: 'POST',
|
62
|
+
headers: {
|
63
|
+
'Content-Type': 'application/json',
|
64
|
+
'Content-Encoding': 'gzip',
|
65
|
+
'Authorization': `Bearer ${projectApiKey}`,
|
66
|
+
'Accept': 'application/json'
|
67
|
+
},
|
68
|
+
body: blob,
|
69
|
+
mode: 'cors',
|
70
|
+
credentials: 'omit'
|
71
|
+
});
|
72
|
+
|
73
|
+
if (!response.ok) {
|
74
|
+
console.error(`HTTP error! status: ${response.status}`);
|
75
|
+
if (response.status === 0) {
|
76
|
+
console.error('Possible CORS issue - check network tab for details');
|
77
|
+
}
|
78
|
+
}
|
79
|
+
|
80
|
+
window.lmnrRrwebEventsBatch = [];
|
81
|
+
} catch (error) {
|
82
|
+
console.error('Failed to send events:', error);
|
83
|
+
}
|
84
|
+
};
|
85
|
+
|
86
|
+
setInterval(() => window.lmnrSendRrwebEventsBatch(), FLUSH_INTERVAL);
|
87
|
+
|
88
|
+
setInterval(() => {
|
89
|
+
window.lmnrRrwebEventsBatch.push({
|
90
|
+
type: 6,
|
91
|
+
data: { source: 'heartbeat' },
|
92
|
+
timestamp: Date.now()
|
93
|
+
});
|
94
|
+
}, HEARTBEAT_INTERVAL);
|
95
|
+
|
96
|
+
window.lmnrRrweb.record({
|
97
|
+
emit(event) {
|
98
|
+
window.lmnrRrwebEventsBatch.push(event);
|
99
|
+
}
|
100
|
+
});
|
101
|
+
|
102
|
+
window.addEventListener('beforeunload', () => {
|
103
|
+
window.lmnrSendRrwebEventsBatch();
|
104
|
+
});
|
105
|
+
}
|
106
|
+
"""
|
107
|
+
|
108
|
+
|
109
|
+
def retry_sync(func, retries=5, delay=0.5, error_message="Operation failed"):
|
110
|
+
"""Utility function for retry logic in synchronous operations"""
|
111
|
+
for attempt in range(retries):
|
112
|
+
try:
|
113
|
+
result = func()
|
114
|
+
if result: # If function returns truthy value, consider it successful
|
115
|
+
return result
|
116
|
+
if attempt == retries - 1: # Last attempt
|
117
|
+
logger.error(f"{error_message} after all retries")
|
118
|
+
return None
|
119
|
+
except Exception as e:
|
120
|
+
if attempt == retries - 1: # Last attempt
|
121
|
+
logger.error(f"{error_message}: {e}")
|
122
|
+
return None
|
123
|
+
time.sleep(delay)
|
124
|
+
return None
|
125
|
+
|
126
|
+
|
127
|
+
async def retry_async(func, retries=5, delay=0.5, error_message="Operation failed"):
|
128
|
+
"""Utility function for retry logic in asynchronous operations"""
|
129
|
+
for attempt in range(retries):
|
130
|
+
try:
|
131
|
+
result = await func()
|
132
|
+
if result: # If function returns truthy value, consider it successful
|
133
|
+
return result
|
134
|
+
if attempt == retries - 1: # Last attempt
|
135
|
+
logger.error(f"{error_message} after all retries")
|
136
|
+
return None
|
137
|
+
except Exception as e:
|
138
|
+
if attempt == retries - 1: # Last attempt
|
139
|
+
logger.error(f"{error_message}: {e}")
|
140
|
+
return None
|
141
|
+
await asyncio.sleep(delay)
|
142
|
+
return None
|
143
|
+
|
144
|
+
|
145
|
+
def init_playwright_tracing(http_url: str, project_api_key: str):
|
146
|
+
|
147
|
+
def inject_rrweb(page: SyncPage):
|
148
|
+
# Wait for the page to be in a ready state first
|
149
|
+
page.wait_for_load_state("domcontentloaded")
|
150
|
+
|
151
|
+
# First check if rrweb is already loaded
|
152
|
+
is_loaded = page.evaluate(
|
153
|
+
"""
|
154
|
+
() => typeof window.lmnrRrweb !== 'undefined'
|
155
|
+
"""
|
156
|
+
)
|
157
|
+
|
158
|
+
if not is_loaded:
|
159
|
+
|
160
|
+
def load_rrweb():
|
161
|
+
page.evaluate(RRWEB_CONTENT)
|
162
|
+
# Verify script loaded successfully
|
163
|
+
page.wait_for_function(
|
164
|
+
"""(() => typeof window.lmnrRrweb !== 'undefined')""",
|
165
|
+
timeout=5000,
|
166
|
+
)
|
167
|
+
return True
|
168
|
+
|
169
|
+
if not retry_sync(
|
170
|
+
load_rrweb, delay=1, error_message="Failed to load rrweb"
|
171
|
+
):
|
172
|
+
return
|
173
|
+
|
174
|
+
# Get current trace ID from active span
|
175
|
+
current_span = opentelemetry.trace.get_current_span()
|
176
|
+
if current_span.is_recording():
|
177
|
+
current_span.set_attribute("lmnr.internal.has_browser_session", True)
|
178
|
+
|
179
|
+
trace_id = format(current_span.get_span_context().trace_id, "032x")
|
180
|
+
session_id = str(uuid.uuid4().hex)
|
181
|
+
|
182
|
+
def set_window_vars():
|
183
|
+
page.evaluate(
|
184
|
+
"""([traceId, sessionId]) => {
|
185
|
+
window.lmnrRrwebSessionId = sessionId;
|
186
|
+
window.lmnrTraceId = traceId;
|
187
|
+
}""",
|
188
|
+
[trace_id, session_id],
|
189
|
+
)
|
190
|
+
return page.evaluate(
|
191
|
+
"""
|
192
|
+
() => window.lmnrRrwebSessionId && window.lmnrTraceId
|
193
|
+
"""
|
194
|
+
)
|
195
|
+
|
196
|
+
if not retry_sync(
|
197
|
+
set_window_vars, error_message="Failed to set window variables"
|
198
|
+
):
|
199
|
+
return
|
200
|
+
|
201
|
+
# Update the recording setup to include trace ID
|
202
|
+
page.evaluate(
|
203
|
+
INJECT_PLACEHOLDER,
|
204
|
+
[http_url, project_api_key],
|
205
|
+
)
|
206
|
+
|
207
|
+
async def inject_rrweb_async(page: Page):
|
208
|
+
# Wait for the page to be in a ready state first
|
209
|
+
await page.wait_for_load_state("domcontentloaded")
|
210
|
+
|
211
|
+
# First check if rrweb is already loaded
|
212
|
+
is_loaded = await page.evaluate(
|
213
|
+
"""
|
214
|
+
() => typeof window.lmnrRrweb !== 'undefined'
|
215
|
+
"""
|
216
|
+
)
|
217
|
+
|
218
|
+
if not is_loaded:
|
219
|
+
|
220
|
+
async def load_rrweb():
|
221
|
+
await page.evaluate(RRWEB_CONTENT)
|
222
|
+
# Verify script loaded successfully
|
223
|
+
await page.wait_for_function(
|
224
|
+
"""(() => typeof window.lmnrRrweb !== 'undefined')""",
|
225
|
+
timeout=5000,
|
226
|
+
)
|
227
|
+
return True
|
228
|
+
|
229
|
+
if not await retry_async(
|
230
|
+
load_rrweb, delay=1, error_message="Failed to load rrweb"
|
231
|
+
):
|
232
|
+
return
|
233
|
+
|
234
|
+
# Get current trace ID from active span
|
235
|
+
current_span = opentelemetry.trace.get_current_span()
|
236
|
+
if current_span.is_recording():
|
237
|
+
current_span.set_attribute("lmnr.internal.has_browser_session", True)
|
238
|
+
|
239
|
+
trace_id = format(current_span.get_span_context().trace_id, "032x")
|
240
|
+
session_id = str(uuid.uuid4().hex)
|
241
|
+
|
242
|
+
async def set_window_vars():
|
243
|
+
await page.evaluate(
|
244
|
+
"""([traceId, sessionId]) => {
|
245
|
+
window.lmnrRrwebSessionId = sessionId;
|
246
|
+
window.lmnrTraceId = traceId;
|
247
|
+
}""",
|
248
|
+
[trace_id, session_id],
|
249
|
+
)
|
250
|
+
return await page.evaluate(
|
251
|
+
"""
|
252
|
+
() => window.lmnrRrwebSessionId && window.lmnrTraceId
|
253
|
+
"""
|
254
|
+
)
|
255
|
+
|
256
|
+
if not await retry_async(
|
257
|
+
set_window_vars, error_message="Failed to set window variables"
|
258
|
+
):
|
259
|
+
return
|
260
|
+
|
261
|
+
# Update the recording setup to include trace ID
|
262
|
+
await page.evaluate(
|
263
|
+
INJECT_PLACEHOLDER,
|
264
|
+
[http_url, project_api_key],
|
265
|
+
)
|
266
|
+
|
267
|
+
def handle_navigation(page: SyncPage):
|
268
|
+
def on_load():
|
269
|
+
inject_rrweb(page)
|
270
|
+
|
271
|
+
page.on("load", on_load)
|
272
|
+
inject_rrweb(page)
|
273
|
+
|
274
|
+
async def handle_navigation_async(page: Page):
|
275
|
+
async def on_load():
|
276
|
+
await inject_rrweb_async(page)
|
277
|
+
|
278
|
+
page.on("load", lambda: asyncio.create_task(on_load()))
|
279
|
+
await inject_rrweb_async(page)
|
280
|
+
|
281
|
+
async def patched_new_page_async(self: BrowserContext, *args, **kwargs):
|
282
|
+
# Modify CSP to allow required domains
|
283
|
+
async def handle_route(route):
|
284
|
+
try:
|
285
|
+
response = await route.fetch()
|
286
|
+
headers = dict(response.headers)
|
287
|
+
|
288
|
+
# Find and modify CSP header
|
289
|
+
for header_name in headers:
|
290
|
+
if header_name.lower() == "content-security-policy":
|
291
|
+
csp = headers[header_name]
|
292
|
+
parts = csp.split(";")
|
293
|
+
for i, part in enumerate(parts):
|
294
|
+
if "connect-src" in part:
|
295
|
+
parts[i] = f"{part.strip()} {http_url}"
|
296
|
+
headers[header_name] = ";".join(parts)
|
297
|
+
|
298
|
+
await route.fulfill(response=response, headers=headers)
|
299
|
+
except Exception as e:
|
300
|
+
logger.debug(f"Error handling route: {e}")
|
301
|
+
await route.continue_()
|
302
|
+
|
303
|
+
# Intercept all navigation requests to modify CSP headers
|
304
|
+
await self.route("**/*", handle_route)
|
305
|
+
page = await _original_new_page_async(self, *args, **kwargs)
|
306
|
+
await handle_navigation_async(page)
|
307
|
+
return page
|
308
|
+
|
309
|
+
def patched_new_page(self: SyncBrowserContext, *args, **kwargs):
|
310
|
+
# Modify CSP to allow required domains
|
311
|
+
def handle_route(route):
|
312
|
+
try:
|
313
|
+
response = route.fetch()
|
314
|
+
headers = dict(response.headers)
|
315
|
+
|
316
|
+
# Find and modify CSP header
|
317
|
+
for header_name in headers:
|
318
|
+
if header_name.lower() == "content-security-policy":
|
319
|
+
csp = headers[header_name]
|
320
|
+
parts = csp.split(";")
|
321
|
+
for i, part in enumerate(parts):
|
322
|
+
if "connect-src" in part:
|
323
|
+
parts[i] = f"{part.strip()} {http_url}"
|
324
|
+
if not any("connect-src" in part for part in parts):
|
325
|
+
parts.append(f" connect-src 'self' {http_url}")
|
326
|
+
headers[header_name] = ";".join(parts)
|
327
|
+
|
328
|
+
route.fulfill(response=response, headers=headers)
|
329
|
+
except Exception as e:
|
330
|
+
logger.debug(f"Error handling route: {e}")
|
331
|
+
route.continue_()
|
332
|
+
|
333
|
+
# Intercept all navigation requests to modify CSP headers
|
334
|
+
self.route("**/*", handle_route)
|
335
|
+
page = _original_new_page(self, *args, **kwargs)
|
336
|
+
handle_navigation(page)
|
337
|
+
return page
|
338
|
+
|
339
|
+
def patch_browser():
|
340
|
+
global _original_new_page, _original_new_page_async
|
341
|
+
if _original_new_page_async is None:
|
342
|
+
_original_new_page_async = BrowserContext.new_page
|
343
|
+
BrowserContext.new_page = patched_new_page_async
|
344
|
+
|
345
|
+
if _original_new_page is None:
|
346
|
+
_original_new_page = SyncBrowserContext.new_page
|
347
|
+
SyncBrowserContext.new_page = patched_new_page
|
348
|
+
|
349
|
+
patch_browser()
|