Skip to content

Commit 424ca2e

Browse files
author
Andrei Bratu
committed
draft
1 parent a652d9e commit 424ca2e

File tree

15 files changed

+901
-174
lines changed

15 files changed

+901
-174
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ __pycache__/
44
poetry.toml
55
.ruff_cache/
66
.vscode
7-
.env
7+
.env

src/humanloop/client.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ def run(
4949
name: Optional[str],
5050
dataset: Dataset,
5151
evaluators: Optional[Sequence[Evaluator]] = None,
52-
# logs: typing.Sequence[dict] | None = None,
5352
workers: int = 4,
5453
) -> List[EvaluatorCheck]:
5554
"""Evaluate your function for a given `Dataset` and set of `Evaluators`.

src/humanloop/eval_utils/run.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -212,10 +212,6 @@ def increment(self):
212212
sys.stderr.write("\n")
213213

214214

215-
# Module-level so it can be shared by threads.
216-
_PROGRESS_BAR: Optional[_SimpleProgressBar] = None
217-
218-
219215
def run_eval(
220216
client: "BaseHumanloop",
221217
file: File,
@@ -236,7 +232,6 @@ def run_eval(
236232
:param workers: the number of threads to process datapoints using your function concurrently.
237233
:return: per Evaluator checks.
238234
"""
239-
global _PROGRESS_BAR
240235

241236
if hasattr(file["callable"], "file"):
242237
# When the decorator inside `file` is a decorated function,

src/humanloop/otel/__init__.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from opentelemetry.sdk.trace import TracerProvider
44
from typing_extensions import NotRequired
5+
from opentelemetry.sdk.trace import TracerProvider
56

67
from humanloop.otel.helpers import module_is_installed
78

@@ -41,12 +42,3 @@ def instrument_provider(provider: TracerProvider):
4142
from opentelemetry.instrumentation.bedrock import BedrockInstrumentor
4243

4344
BedrockInstrumentor().instrument(tracer_provider=provider)
44-
45-
46-
class FlowContext(TypedDict):
47-
trace_id: NotRequired[str]
48-
trace_parent_id: NotRequired[Optional[int]]
49-
is_flow_log: NotRequired[bool]
50-
51-
52-
TRACE_FLOW_CONTEXT: dict[int, FlowContext] = {}

src/humanloop/otel/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@
44
HUMANLOOP_LOG_KEY = "humanloop.log"
55
HUMANLOOP_FILE_TYPE_KEY = "humanloop.file.type"
66
HUMANLOOP_PATH_KEY = "humanloop.file.path"
7+
# Required for the exporter to know when to mark the Flow Log as complete
8+
HUMANLOOP_FLOW_PREREQUISITES_KEY = "humanloop.flow.prerequisites"

src/humanloop/otel/exporter.py

Lines changed: 65 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import contextvars
2-
import json
32
import logging
43
import threading
4+
import time
55
import typing
66
from queue import Empty as EmptyQueue
77
from queue import Queue
@@ -14,16 +14,18 @@
1414

1515
from humanloop.core import ApiError as HumanloopApiError
1616
from humanloop.eval_utils.context import EVALUATION_CONTEXT_VARIABLE_NAME, EvaluationContext
17-
from humanloop.otel import TRACE_FLOW_CONTEXT, FlowContext
17+
from humanloop.otel import TRACE_FLOW_CONTEXT
1818
from humanloop.otel.constants import (
1919
HUMANLOOP_FILE_KEY,
2020
HUMANLOOP_FILE_TYPE_KEY,
21+
HUMANLOOP_FLOW_PREREQUISITES_KEY,
2122
HUMANLOOP_LOG_KEY,
2223
HUMANLOOP_PATH_KEY,
2324
)
2425
from humanloop.otel.helpers import is_humanloop_span, read_from_opentelemetry_span
2526
from humanloop.requests.flow_kernel_request import FlowKernelRequestParams
2627
from humanloop.requests.prompt_kernel_request import PromptKernelRequestParams
28+
from humanloop.requests.tool_kernel_request import ToolKernelRequestParams
2729

2830
if typing.TYPE_CHECKING:
2931
from humanloop.client import Humanloop
@@ -69,7 +71,8 @@ def __init__(
6971
for thread in self._threads:
7072
thread.start()
7173
logger.debug("Exporter Thread %s started", thread.ident)
72-
self._flow_logs_to_complete: list[str] = []
74+
# Flow Log Span ID mapping to children Spans that must be uploaded first
75+
self._flow_log_prerequisites: dict[int, set[int]] = {}
7376

7477
def export(self, spans: trace.Sequence[ReadableSpan]) -> SpanExportResult:
7578
def is_evaluated_file(
@@ -133,11 +136,6 @@ def shutdown(self) -> None:
133136
for thread in self._threads:
134137
thread.join()
135138
logger.debug("Exporter Thread %s joined", thread.ident)
136-
for log_id in self._flow_logs_to_complete:
137-
self._client.flows.update_log(
138-
log_id=log_id,
139-
trace_status="complete",
140-
)
141139

142140
def force_flush(self, timeout_millis: int = 3000) -> bool:
143141
self._shutdown = True
@@ -211,9 +209,22 @@ def _do_work(self):
211209
self._upload_queue.put((span_to_export, evaluation_context))
212210
self._upload_queue.task_done()
213211

212+
def _complete_flow_log(self, span_id: int) -> None:
213+
for flow_log_span_id, flow_children_span_ids in self._flow_log_prerequisites.items():
214+
if span_id in flow_children_span_ids:
215+
flow_children_span_ids.remove(span_id)
216+
if len(flow_children_span_ids) == 0:
217+
flow_log_id = self._span_id_to_uploaded_log_id[flow_log_span_id]
218+
self._client.flows.update_log(log_id=flow_log_id, trace_status="complete")
219+
break
220+
214221
def _export_span_dispatch(self, span: ReadableSpan) -> None:
215222
hl_file = read_from_opentelemetry_span(span, key=HUMANLOOP_FILE_KEY)
216223
file_type = span._attributes.get(HUMANLOOP_FILE_TYPE_KEY) # type: ignore
224+
parent_span_id = span.parent.span_id if span.parent else None
225+
226+
while parent_span_id and self._span_id_to_uploaded_log_id.get(parent_span_id) is None:
227+
time.sleep(0.1)
217228

218229
if file_type == "prompt":
219230
export_func = self._export_prompt
@@ -242,25 +253,16 @@ def _export_prompt(self, span: ReadableSpan) -> None:
242253
log_object["messages"] = []
243254
if "tools" not in file_object["prompt"]:
244255
file_object["prompt"]["tools"] = []
245-
trace_metadata = TRACE_FLOW_CONTEXT.get(span.get_span_context().span_id)
246-
if trace_metadata and "trace_parent_id" in trace_metadata and trace_metadata["trace_parent_id"]:
247-
trace_parent_id = self._span_id_to_uploaded_log_id[trace_metadata["trace_parent_id"]]
248-
if trace_parent_id is None:
249-
# Parent Log in Trace upload failed
250-
file_path = read_from_opentelemetry_span(span, key=HUMANLOOP_PATH_KEY)
251-
logger.error(f"Skipping log for {file_path}: parent Log upload failed")
252-
return
253-
else:
254-
trace_parent_id = None
255-
prompt: PromptKernelRequestParams = file_object["prompt"]
256+
256257
path: str = file_object["path"]
257-
if "output" in log_object:
258-
if not isinstance(log_object["output"], str):
259-
# Output expected to be a string, if decorated function
260-
# does not return one, jsonify it
261-
log_object["output"] = json.dumps(log_object["output"])
258+
prompt: PromptKernelRequestParams = file_object["prompt"]
259+
260+
span_parent_id = span.parent.span_id if span.parent else None
261+
trace_parent_id = self._span_id_to_uploaded_log_id[span_parent_id] if span_parent_id else None
262+
262263
if "attributes" not in prompt or not prompt["attributes"]:
263264
prompt["attributes"] = {}
265+
264266
try:
265267
log_response = self._client.prompts.log(
266268
path=path,
@@ -271,34 +273,32 @@ def _export_prompt(self, span: ReadableSpan) -> None:
271273
self._span_id_to_uploaded_log_id[span.context.span_id] = log_response.id
272274
except HumanloopApiError:
273275
self._span_id_to_uploaded_log_id[span.context.span_id] = None
276+
self._complete_flow_log(span_id=span.context.span_id)
274277

275278
def _export_tool(self, span: ReadableSpan) -> None:
276-
file_object: dict[str, Any] = read_from_opentelemetry_span(span, key=HUMANLOOP_FILE_KEY)
277-
log_object: dict[str, Any] = read_from_opentelemetry_span(span, key=HUMANLOOP_LOG_KEY)
278-
trace_metadata: FlowContext = TRACE_FLOW_CONTEXT.get(span.get_span_context().span_id, {})
279-
if "trace_parent_id" in trace_metadata and trace_metadata["trace_parent_id"]:
280-
trace_parent_id = self._span_id_to_uploaded_log_id.get(
281-
trace_metadata["trace_parent_id"],
282-
)
283-
if trace_parent_id is None:
284-
# Parent Log in Trace upload failed
285-
file_path = read_from_opentelemetry_span(span, key=HUMANLOOP_PATH_KEY)
286-
logger.error(f"Skipping log for {file_path}: parent Log upload failed")
287-
return
288-
else:
289-
trace_parent_id = None
290-
tool = file_object["tool"]
279+
file_object: dict[str, Any] = read_from_opentelemetry_span(
280+
span,
281+
key=HUMANLOOP_FILE_KEY,
282+
)
283+
log_object: dict[str, Any] = read_from_opentelemetry_span(
284+
span,
285+
key=HUMANLOOP_LOG_KEY,
286+
)
287+
288+
path: str = file_object["path"]
289+
tool: ToolKernelRequestParams = file_object["tool"]
290+
291+
span_parent_id = span.parent.span_id if span.parent else None
292+
trace_parent_id = self._span_id_to_uploaded_log_id[span_parent_id] if span_parent_id else None
293+
294+
# API expects an empty dictionary if user does not supply attributes
291295
if not tool.get("attributes"):
292296
tool["attributes"] = {}
293297
if not tool.get("setup_values"):
294298
tool["setup_values"] = {}
295-
path: str = file_object["path"]
296299
if "parameters" in tool["function"] and "properties" not in tool["function"]["parameters"]:
297300
tool["function"]["parameters"]["properties"] = {}
298-
if not isinstance(log_object["output"], str):
299-
# Output expected to be a string, if decorated function
300-
# does not return one, jsonify it
301-
log_object["output"] = json.dumps(log_object["output"])
301+
302302
try:
303303
log_response = self._client.tools.log(
304304
path=path,
@@ -309,33 +309,34 @@ def _export_tool(self, span: ReadableSpan) -> None:
309309
self._span_id_to_uploaded_log_id[span.context.span_id] = log_response.id
310310
except HumanloopApiError:
311311
self._span_id_to_uploaded_log_id[span.context.span_id] = None
312+
self._complete_flow_log(span_id=span.context.span_id)
312313

313314
def _export_flow(self, span: ReadableSpan) -> None:
314-
file_object: dict[str, Any] = read_from_opentelemetry_span(span, key=HUMANLOOP_FILE_KEY)
315-
log_object: dict[str, Any] = read_from_opentelemetry_span(span, key=HUMANLOOP_LOG_KEY)
316-
trace_metadata: FlowContext = TRACE_FLOW_CONTEXT.get(
317-
span.get_span_context().span_id,
318-
{},
315+
file_object: dict[str, Any] = read_from_opentelemetry_span(
316+
span,
317+
key=HUMANLOOP_FILE_KEY,
319318
)
320-
if "trace_parent_id" in trace_metadata:
321-
trace_parent_id = self._span_id_to_uploaded_log_id.get(
322-
trace_metadata["trace_parent_id"], # type: ignore
323-
)
324-
if trace_parent_id is None and trace_metadata["trace_id"] != span.get_span_context().span_id:
325-
# Parent Log in Trace upload failed
326-
# NOTE: Check if the trace_id metadata field points to the
327-
# span itself. This signifies the span is the head of the Trace
328-
file_path = read_from_opentelemetry_span(span, key=HUMANLOOP_PATH_KEY)
329-
logger.error(f"Skipping log for {file_path}: parent Log upload failed")
330-
return
331-
else:
332-
trace_parent_id = None
319+
log_object: dict[str, Any] = read_from_opentelemetry_span(
320+
span,
321+
key=HUMANLOOP_LOG_KEY,
322+
)
323+
# Spans that must be uploaded before the Flow Span is completed
324+
prerequisites = read_from_opentelemetry_span(
325+
span=span,
326+
key=HUMANLOOP_FLOW_PREREQUISITES_KEY,
327+
)
328+
self._flow_log_prerequisites[span.context.span_id] = set(prerequisites)
329+
330+
path: str = file_object["path"]
333331
flow: FlowKernelRequestParams
334332
if not file_object.get("flow"):
335333
flow = {"attributes": {}}
336334
else:
337335
flow = file_object["flow"]
338-
path: str = file_object["path"]
336+
337+
span_parent_id = span.parent.span_id if span.parent else None
338+
trace_parent_id = self._span_id_to_uploaded_log_id[span_parent_id] if span_parent_id else None
339+
339340
if "output" not in log_object:
340341
log_object["output"] = None
341342
try:
@@ -350,3 +351,4 @@ def _export_flow(self, span: ReadableSpan) -> None:
350351
except HumanloopApiError as e:
351352
logger.error(str(e))
352353
self._span_id_to_uploaded_log_id[span.context.span_id] = None
354+
self._complete_flow_log(span_id=span.context.span_id)

src/humanloop/otel/helpers.py

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -267,13 +267,7 @@ def is_llm_provider_call(span: ReadableSpan) -> bool:
267267

268268
def is_humanloop_span(span: ReadableSpan) -> bool:
269269
"""Check if the Span was created by the Humanloop SDK."""
270-
try:
271-
# Valid spans will have keys with the HL_FILE_OT_KEY and HL_LOG_OT_KEY prefixes present
272-
read_from_opentelemetry_span(span, key=HUMANLOOP_FILE_KEY)
273-
read_from_opentelemetry_span(span, key=HUMANLOOP_LOG_KEY)
274-
except KeyError:
275-
return False
276-
return True
270+
return span.name.startswith("humanloop.")
277271

278272

279273
def module_is_installed(module_name: str) -> bool:
@@ -288,10 +282,6 @@ def module_is_installed(module_name: str) -> bool:
288282
return True
289283

290284

291-
def generate_span_id() -> str:
292-
return str(uuid.uuid4())
293-
294-
295285
def jsonify_if_not_string(func: Callable, output: Any) -> str:
296286
if not isinstance(output, str):
297287
try:

src/humanloop/otel/processor.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22
from collections import defaultdict
33
from typing import Any
44

5-
# No typing stubs for parse
65
from opentelemetry.sdk.trace import ReadableSpan
76
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter
87
from pydantic import ValidationError as PydanticValidationError
98

10-
from humanloop.otel.constants import HUMANLOOP_FILE_KEY, HUMANLOOP_FILE_TYPE_KEY, HUMANLOOP_LOG_KEY
9+
from humanloop.otel.constants import (
10+
HUMANLOOP_FILE_KEY,
11+
HUMANLOOP_FILE_TYPE_KEY,
12+
HUMANLOOP_FLOW_PREREQUISITES_KEY,
13+
HUMANLOOP_LOG_KEY,
14+
)
1115
from humanloop.otel.helpers import (
1216
is_humanloop_span,
1317
is_llm_provider_call,
@@ -40,10 +44,17 @@ def __init__(self, exporter: SpanExporter) -> None:
4044
super().__init__(exporter)
4145
# Span parent to Span children map
4246
self._children: dict[int, list] = defaultdict(list)
43-
44-
# NOTE: Could override on_start and process Flow spans ahead of time
45-
# and PATCH the created Logs in on_end. A special type of ReadableSpan could be
46-
# used for this
47+
self._prerequisites: dict[int, list[int]] = {}
48+
49+
def on_start(self, span, parent_context=None):
50+
span_id = span.context.span_id
51+
if span.name == "humanloop.flow":
52+
self._prerequisites[span_id] = []
53+
if span.parent and is_humanloop_span(span):
54+
parent_span_id = span.parent.span_id
55+
for trace_head, all_trace_nodes in self._prerequisites.items():
56+
if parent_span_id == trace_head or parent_span_id in all_trace_nodes:
57+
all_trace_nodes.append(span_id)
4758

4859
def on_end(self, span: ReadableSpan) -> None:
4960
if is_humanloop_span(span=span):
@@ -57,6 +68,12 @@ def on_end(self, span: ReadableSpan) -> None:
5768
# arrives in order to enrich it
5869
self._children[span.parent.span_id].append(span)
5970
# Pass the Span to the Exporter
71+
if span.name == "humanloop.flow":
72+
write_to_opentelemetry_span(
73+
span=span,
74+
key=HUMANLOOP_FLOW_PREREQUISITES_KEY,
75+
value=self._prerequisites[span.context.span_id],
76+
)
6077
self.span_exporter.export([span])
6178

6279

0 commit comments

Comments
 (0)