feat: add OpenTelemetry metrics for create jobs#802
Conversation
|
Fern preview: https://nvidia-preview-pr-802.docs.buildwithfern.com/nemo/datadesigner
|
Code Review: PR #802 — feat: add OpenTelemetry metrics for create jobsAuthor: eric-tramel · Base: SummaryThis PR adds default, pull-based OpenTelemetry/Prometheus metrics for The design correctly respects the package layering. OTel SDK/exporter ownership stays in The change is unusually well-tested: privacy (secret-sentinel scrubbing), lifecycle/rebind, concurrent creates, exact-once emission, init-failure cleanup, real HTTP scrape, and a subprocess test asserting the OTel SDK is not imported when disabled. Failure containment is a clear design goal and is consistently applied. FindingsCorrectness
Design / Architecture
Security / Privacy
Performance
Testing
Docs
Nits
Structural ImpactPre-computed by graphify; annotated with reviewer verification. Risk: HIGH (1 core abstraction(s) modified; 27 import direction violation(s))
Import Direction Violations (27)Legal direction: interface -> engine -> config
Core Abstractions Modified
High-Connectivity Changes
Cross-Package Dependencies
Reviewer note on the 27 import-direction violations: these are false positives from call-graph attribution. The flagged VerdictApprove (with minor, non-blocking nits). This is a high-quality, well-architected PR that respects the interface → engine → config layering, contains all telemetry failures so they cannot alter create results, and has strong privacy guarantees backed by explicit tests. The scheduler Suggested (optional) follow-ups: dedupe the 3× enable-check ternary in |
Greptile SummaryThis PR adds pull-based OpenTelemetry/Prometheus metrics for
|
| Filename | Overview |
|---|---|
| packages/data-designer/src/data_designer/integrations/opentelemetry.py | New file: implements OpenTelemetryRuntime with per-run lifecycle management, gauge reconciliation, and Prometheus HTTP listener; _stop_server is correctly called outside the RLock to avoid the scrape-handler deadlock raised in a previous review thread. |
| packages/data-designer-engine/src/data_designer/engine/observability.py | Adds SchedulerAdmissionEventKind/RequestAdmissionEventKind type aliases, scheduler_event_sink_accepts/request_event_sink_accepts interest-filtering helpers, and a _FanoutSchedulerEventSink that isolates per-sink failures; logic is correct and well-tested. |
| packages/data-designer-engine/src/data_designer/engine/dataset_builders/async_scheduler.py | Moves scheduler_job_completed into a finally block covering all terminal outcomes (success, early_shutdown, cancelled, error); health diagnostics are attached only for non-error outcomes to avoid expensive snapshots on abnormal exits. |
| packages/data-designer-engine/src/data_designer/engine/models/clients/model_request_executor.py | Refactors _execute_sync and _execute_async to emit model_request_completed in a single finally block with accurate duration_seconds; all exception paths (ProviderError, TimeoutError, CancelledError, BaseException) correctly set outcome before the finally runs. |
| packages/data-designer/src/data_designer/interface/data_designer.py | Wires OpenTelemetryRuntime into DataDesigner via an _observe_create decorator on create(); scheduler and request event sinks are consistently gated on otel_metrics_port is not None in both _create_resource_provider and _create_request_admission_controller. |
| packages/data-designer-config/src/data_designer/config/run_config.py | Adds otel_metrics_port: int |
| packages/data-designer-engine/src/data_designer/engine/resources/resource_provider.py | Adds scheduler_event_sink and request_event_sink parameters to create_resource_provider; the scheduler_event_sink is stored on ResourceProvider and fan-out composed with the JSONL sink in DatasetBuilder. |
| packages/data-designer/tests/integrations/test_opentelemetry.py | New test file with ~996 lines covering runtime lifecycle, event filtering, concurrent runs, port rebind, progress tracking, log handler, and integration with a real HTTP scrape; comprehensive coverage of the new OTel integration. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant User
participant DataDesigner
participant OTelRuntime as OpenTelemetryRuntime
participant Scheduler as AsyncTaskScheduler
participant Executor as ModelRequestExecutor
participant Prometheus
User->>DataDesigner: create(config_builder)
DataDesigner->>OTelRuntime: observe_create(port)
OTelRuntime->>OTelRuntime: _activate_run(run_id, port) - start HTTP server outside lock
OTelRuntime-->>DataDesigner: run_id in _active_run_ids
DataDesigner->>Scheduler: build(num_records)
Scheduler->>OTelRuntime: emit_scheduler_event(scheduler_job_started)
OTelRuntime->>OTelRuntime: _start_dataset_progress(run_id, total)
loop per row-group
Scheduler->>Executor: execute request
Executor->>OTelRuntime: emit_request_event(model_request_started)
OTelRuntime->>OTelRuntime: _active_model_requests.add(+1)
Executor->>Executor: call() - LLM
Executor->>OTelRuntime: emit_request_event(model_request_completed, duration)
OTelRuntime->>OTelRuntime: _active_model_requests.add(-1) + _request_duration.record()
Scheduler->>OTelRuntime: emit_scheduler_event(row_group_checkpointed)
OTelRuntime->>OTelRuntime: _advance_dataset_progress(run_id)
Prometheus->>OTelRuntime: GET /metrics
OTelRuntime-->>Prometheus: progress, active_requests, counters
end
Scheduler->>OTelRuntime: emit_scheduler_event(scheduler_job_completed)
OTelRuntime->>OTelRuntime: _finish_active_dataset(run_id)
DataDesigner->>OTelRuntime: observe_create exits (finally)
OTelRuntime->>OTelRuntime: discard run_id + _finish_active_requests reconciliation
OTelRuntime->>OTelRuntime: _record_create_duration
Note over OTelRuntime,Prometheus: HTTP server stays up for final scrape
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant User
participant DataDesigner
participant OTelRuntime as OpenTelemetryRuntime
participant Scheduler as AsyncTaskScheduler
participant Executor as ModelRequestExecutor
participant Prometheus
User->>DataDesigner: create(config_builder)
DataDesigner->>OTelRuntime: observe_create(port)
OTelRuntime->>OTelRuntime: _activate_run(run_id, port) - start HTTP server outside lock
OTelRuntime-->>DataDesigner: run_id in _active_run_ids
DataDesigner->>Scheduler: build(num_records)
Scheduler->>OTelRuntime: emit_scheduler_event(scheduler_job_started)
OTelRuntime->>OTelRuntime: _start_dataset_progress(run_id, total)
loop per row-group
Scheduler->>Executor: execute request
Executor->>OTelRuntime: emit_request_event(model_request_started)
OTelRuntime->>OTelRuntime: _active_model_requests.add(+1)
Executor->>Executor: call() - LLM
Executor->>OTelRuntime: emit_request_event(model_request_completed, duration)
OTelRuntime->>OTelRuntime: _active_model_requests.add(-1) + _request_duration.record()
Scheduler->>OTelRuntime: emit_scheduler_event(row_group_checkpointed)
OTelRuntime->>OTelRuntime: _advance_dataset_progress(run_id)
Prometheus->>OTelRuntime: GET /metrics
OTelRuntime-->>Prometheus: progress, active_requests, counters
end
Scheduler->>OTelRuntime: emit_scheduler_event(scheduler_job_completed)
OTelRuntime->>OTelRuntime: _finish_active_dataset(run_id)
DataDesigner->>OTelRuntime: observe_create exits (finally)
OTelRuntime->>OTelRuntime: discard run_id + _finish_active_requests reconciliation
OTelRuntime->>OTelRuntime: _record_create_duration
Note over OTelRuntime,Prometheus: HTTP server stays up for final scrape
Reviews (6): Last reviewed commit: "fix: harden OpenTelemetry runtime" | Re-trigger Greptile
andreatgretel
left a comment
There was a problem hiding this comment.
One inline finding.
| raise self._provider_error_from_request_admission(exc) from exc | ||
| except asyncio.CancelledError: | ||
| raise | ||
| self._emit_model_event("model_request_started", item=item, lease=lease) |
There was a problem hiding this comment.
[P2] Keep started-event emission inside the lease cleanup scope
The admission lease is already acquired here, but this emission occurs before the try that guarantees its release. If KeyboardInterrupt occurs while the sink is running, the coroutine exits with the lease still active. I reproduced active_lease_count == 1 on this head, while the same forced path on main releases it with the local_cancelled outcome. Initialize the timing and outcome variables first, then emit inside the protected try. The synchronous attempt has the same regression.
There was a problem hiding this comment.
This is fixed on the current head as well. Started-event delivery now occurs inside the cleanup scope for both sync and async attempts, so a BaseException during sink delivery still closes the acquired admission lease with the correct terminal outcome. The regression tests force that path and verify the original exception is preserved and the active lease count returns to zero. Current full validation: 3,940 passing tests, one existing skip.
nabinchha
left a comment
There was a problem hiding this comment.
Nice work on this one, @eric-tramel — this is a well-structured, privacy-conscious observability layer with impressively thorough tests.
Summary
Adds default, pull-based OpenTelemetry/Prometheus metrics for DataDesigner.create() on a loopback endpoint controlled by RunConfig.otel_metrics_port (default 9464, None to disable). The interface package owns the OTel SDK, meter provider, and HTTP listener; existing engine scheduler/request event seams feed the exporter, and a metric-only log handler tees severity counts. The implementation matches the PR description, keeps the config → engine → interface layering intact, defers heavy imports into _initialize(), and contains telemetry failures so they can't change create results.
Findings
Warnings — Worth addressing
packages/data-designer/src/data_designer/interface/data_designer.py:173 + run_config.py:194 — Telemetry is on by default, opening a listening socket on every create()
- What:
otel_metrics_portdefaults to9464, so everyDataDesigner.create()in every environment now binds a TCP listener (loopback) and keeps it open until process exit. This is a new, always-on side effect for existing users who never asked for metrics. - Why: In multi-instance hosts, CI, or notebook workflows this can surprise operators, and security scanners routinely flag open ports even on loopback. The graceful-degradation path (bind failure → warn → continue) is good, but the default posture is still "open a socket unless told not to."
- Suggestion: Worth a quick team confirmation that default-on is the intended contract vs. opt-in (e.g. default
None, enabled explicitly). If default-on is deliberate, consider calling it out prominently in the release notes/changelog so operators aren't caught off guard. Not blocking if the team already signed off on this.
packages/data-designer-engine/src/data_designer/engine/models/clients/model_request_executor.py:125,176 — Confirming the existing P2 (started-event emitted before the release-guarding try)
- What: I'm not re-raising new feedback here, just corroborating @andreatgretel's inline comment:
_emit_model_event("model_request_started", ...)runs after the lease is acquired but before thetrythat guarantees release._emit_model_eventswallowsExceptionbut notBaseException, so aKeyboardInterruptduring the started emit leaks the lease. This still reproduces on the current head. - Why: It's a genuine (if narrow) resource-leak window, and the fix is cheap.
- Suggestion: Move the
startedemission inside the protectedtry(initializestarted_at/duration_seconds/outcomefirst, then emit), as suggested in the inline thread. Same for both the sync and async attempt paths.
packages/data-designer-engine/src/data_designer/engine/models/clients/model_request_executor.py:272 — Request correlation relies on the contextvar being present on the calling thread
- What:
_item()readsruntime_correlation_provider.current(), andemit_request_eventdrops any event whosecaptured_correlationhas norun_id. The active-request gauge andgen_ai.client.operation.durationtherefore only populate when the model call executes in a context that inherited the contextvar set byobserve_create. - Why: This is fine for the async path (tasks inherit the context), but for any sync/thread-pool execution mode that runs client calls on worker threads that didn't copy the context,
current()returnsNoneand those requests silently won't appear in the model-activity metrics. Want to make sure that's understood/acceptable. - Suggestion: Could we confirm the concurrency modes that route through
ModelRequestExecutorall preserve the contextvar (or add a targeted test for the sync/threaded path)? If sync-mode gaps are expected, a one-line note in the docs/metric description would set expectations.
packages/data-designer/src/data_designer/integrations/opentelemetry.py — OpenTelemetryRuntime is carrying four responsibilities (SRP)
- What: One ~500-line class owns (a) HTTP server + meter-provider lifecycle, (b) instrument definitions, (c) a domain state machine (active runs, dataset-progress math, in-flight request accounting), and (d) event adaptation for three sinks. The progress aggregation (
_start_dataset_progress/_advance_dataset_progress/_finish_active_dataset/_active_dataset_progress, ~404-435) is a self-contained, OTel-agnostic state machine. - Why: The progress semantics can only be tested by standing up a Prometheus server, and every concern shares the same
_lock. This will get harder to reason about as instruments are added. - Suggestion: Pull the progress math into a small
DatasetProgressTracker(no OTel deps, directly unit-testable) and consider a frozen_MetricInstrumentscontainer for the instruments; leaveOpenTelemetryRuntimeas orchestration + lifecycle. Fine as a follow-up if it's too much for this PR. (Naming:DatasetProgressTrackerstays un-prefixed since the whole point is direct testability — STYLEGUIDE prefers public over private for testable helpers — whereas_MetricInstrumentsis a dumb container tested through the runtime, so the underscore fits.)
packages/data-designer/src/data_designer/integrations/opentelemetry.py:84-96 — ~11 attributes typed Any sidestep the "typed code" invariant
- What:
_registry,_meter_provider,_server,_create_duration,_dataset_records, … are allAnybecause the OTel types aren't imported at module scope. - Why: AGENTS.md/STYLEGUIDE call out avoiding overly broad
Any; as-is the checker can't catch e.g. calling.add()on a histogram vs.record()on a counter. - Suggestion: Import the OTel/prometheus types under
TYPE_CHECKING(the same pattern used for lazy heavy libs) and annotate precisely (Histogram,Counter,UpDownCounter,ObservableGauge,MeterProvider,CollectorRegistry,HTTPServer). This is a low-risk, high-clarity win and pairs naturally with the split above.
model_request_executor.py:287 + data_designer.py:745 — Hot-path events are built before the gate, and preview() pays for events it always drops
- What:
_emit_model_eventunconditionally builds the fullRequestAdmissionEvent— includingself._request_admission.pressure.snapshot(...)and the recursive_json_safenormalization in__post_init__— whenever_event_sink is not None, then dispatches toemit_request_event, which drops it if the run isn't active. Unlike the scheduler path there's noaccepts_request_eventfast-path filter. Because_create_resource_providerwires the sink for bothcreateandpreview(onlycreateactivates a run), every model call on thepreviewpath does this build-then-drop. - Why: Model requests are the highest-volume events, and
previewis latency-sensitive; the per-call snapshot + JSON normalization is wasted work there. - Suggestion: Add an
accepts_request_event(event_kind)gate mirroringscheduler_event_sink_acceptsand check it before building the event / taking the pressure snapshot. That both restores symmetry with the scheduler path and makes the inactive-run/preview case exit cheaply (cleaner than special-casing preview at the wiring site).
Engine observability now has three inconsistent emission seams (strategic design debt) — observability.py, request_admission/controller.py:770-777, async_scheduler.py, model_request_executor.py
- What: The DIP itself is clean (engine defines the sink
Protocols; the interface'sOpenTelemetryRuntimeimplements them; correlation flows the other way via theruntime_correlation_providercontextvar). But the emission side now has three different styles side by side:AsyncScheduler→ single direct sink with anaccepts_scheduler_eventinterest filter.RequestAdmissionController._emit_events→ direct sink and the globalemit_request_admission_eventcallback registry (the latter is howprogress/reporter.pyconsumes events), no filter.ModelRequestExecutor→ single direct sink only (somodel_request_started/completedreach OTel but not the progress reporter), no filter.
- Why: There's no single answer to "how do I observe request activity?" The controller's dual fan-out also has mismatched reliability — the direct-sink call is
try/except-guarded but theemit_request_admission_event(event)call on line 777 is not. As more consumers appear, this divergence compounds. - Suggestion: Converge on one seam over time — e.g. make the progress reporter just another registered subscriber/sink, and give both scheduler and request paths the same optional
accepts_*filter, so the model is uniformly "producer emits event → fan-out to N sinks." Bigger than this PR should take on, but worth capturing as the design debt this feature adds.
packages/data-designer-engine/src/data_designer/engine/observability.py:227 — accepts_scheduler_event is a phantom (undeclared) protocol method
- What:
scheduler_event_sink_acceptsprobesgetattr(sink, "accepts_scheduler_event", None), but that method isn't on theSchedulerAdmissionEventSinkProtocol (which only declaresemit_scheduler_event). - Why: This is a latent bug, not just a style nit — a rename/typo of the method silently degrades to "accept everything" (the filter becomes a no-op), and neither the type checker nor the contract would catch it. Given the filter exists partly for hot-path cost, a silent no-op has real consequences.
- Suggestion: Declare
accepts_scheduler_eventon the Protocol (or aFilteringSchedulerAdmissionEventSinksub-Protocol checked viaisinstance) so the filter is part of the contract instead of duck-typed.
packages/data-designer/src/data_designer/integrations/opentelemetry.py:66-70 — Swallowed exceptions make telemetry bugs undiagnosable (error handling)
- What: The "never break create" policy is applied via bare swallows — e.g.
_MetricLogHandler.emitdoesexcept Exception: passwith no logging at all — so a genuine bug inside the instrumentation is completely invisible in the field. - Why: STYLEGUIDE flags swallowed exceptions as an error-handling problem. The intent (don't break create) is right, but "silently drop with zero trace" means a broken metric would never be noticed or reproducible.
- Suggestion: Keep the swallow, but log at
debugwithexc_info=Trueinstead of barepass, and optionally gate a strict re-raise behind an env var (e.g.DATA_DESIGNER_OTEL_STRICT=1) for tests/field debugging.
packages/data-designer-config/src/data_designer/config/run_config.py:194 — otel_metrics_port=None reads like a kill switch but isn't
- What:
Nonemeans "don't activate for this invocation," yet a listener started by a prior run keeps serving. The field overloads "enabled" and "which port." - Why: This is a user-facing footgun with a security angle — someone who sets
Nonespecifically to stop metrics being served will still have the prior run's endpoint open. Combined with the default-on Warning above, the mismatch between expectation and behavior could bite. - Suggestion: At minimum, clarify in the docstring that
Nonedisables activation for the invocation only (not a process-wide stop), and document how to actually stop the listener. Splitting intoenabled: bool+port: intwould remove the overload but is probably more than needed.
Suggestions — Take it or leave it
Four optional polish items (click to expand)
packages/data-designer-engine/src/data_designer/engine/dataset_builders/dataset_builder.py:976 — Defensive getattr on a field that always exists
- What:
scheduler_event_sink=getattr(self._resource_provider, "scheduler_event_sink", None). Now thatResourceProviderdeclaresscheduler_event_sink: SchedulerAdmissionEventSink | None = None, the attribute always exists. - Why: The
getattrfallback reads as "this might not be there," which is misleading for a declared Pydantic field. - Suggestion: Simplify to
self._resource_provider.scheduler_event_sinkunless there's a compat reason (e.g. externally-constructed providers) — in which case a short comment would help.
packages/data-designer/src/data_designer/interface/data_designer.py:743-757 — The "is telemetry on?" rule is duplicated four times (DRY)
- What:
self._open_telemetry if self._run_config.otel_metrics_port is not None else Noneappears at three call sites (request sink, scheduler sink, request-admission controller), plus the port passed intoobserve_create. - Why: The enablement invariant is smeared across the interface; changing it means touching several spots.
- Suggestion: Extract a
self._metrics_sink()accessor returning the runtime orNone, and use it at each site.
packages/data-designer/src/data_designer/integrations/opentelemetry.py:491-492 — Global singleton + atexit registered as an import side effect
- What: Importing the module constructs
_RUNTIMEand registersatexit.register(_RUNTIME.shutdown). - Why: A process-global lifecycle established just by importing the interface is a classic testability smell (the tests already work around it with a fresh-instance fixture). It's cheap today (no OTel imports fire at construction), so low urgency.
- Suggestion: Optionally make
get_open_telemetry_runtime()construct lazily on first call and registeratexitthere once.
packages/data-designer/src/data_designer/integrations/opentelemetry.py:335-345 — Progress gauge blends independent jobs into one process-wide float
- What:
_dataset_progress_valueis a single number aggregated across all active runs, with the last finished value lingering until the next start. - Why: For concurrent creates, one gauge that shifts between "this job / blended / stale residual" is semantically muddy on a dashboard. Fine for the common single-job case.
- Suggestion: If overlapping creates become normal, prefer a per-run gauge label so each job reports its own fraction. Defer to a follow-up unless multi-job dashboards are an immediate need (it's a metric-schema change).
What Looks Good
- Failure containment is excellent —
_activate_run,_initialize, the log handler,_record_create_duration, and_finish_active_requestsall defensively suppress/rollback so telemetry can never break a create. The partial-init cleanup in_initialize(removing the handler and shutting down the provider on failure) is especially careful. - The P1 deadlock fix is done right —
_rebindnow returns the old(server, thread)and_stop_serverruns outsideself._lockin both_activate_runandshutdown, withtest_runtime_stops_rebound_listener_outside_runtime_locklocking in the behavior. TheRLockchoice for the re-entrant progress helpers is appropriate. - Privacy posture is thorough —
_bounded_attributestrips/bounds attribute values, and the metric-only log handler tees severity counts without ever exporting log bodies. The docs section spells out the loopback-only, no-auth, metrics-only contract clearly. - Test suite is genuinely comprehensive — post-teardown event rejection, orphan/duplicate request reconciliation, idle-listener rebind, conflicting-port reuse, concurrent creates sharing one exporter, and
test_disabled_runtime_does_not_import_otel_sdk_in_fresh_processguarding the lazy-import contract. These verify behavior, not plumbing.
Suggested Follow-up Issues
For the larger design items we're intentionally not folding into this PR, let's open tracking issues so they don't get lost (happy to file these — just confirm):
- Unify the three engine observability emission seams — converge direct-sink / global-registry / sink-with-filter into one "producer → fan-out to N sinks" model, including guarding the unguarded
emit_request_admission_eventcall and giving both paths anaccepts_*filter. (Strategic; highest-value.) - Split
OpenTelemetryRuntime— extractDatasetProgressTracker(directly unit-testable) and a typed_MetricInstrumentscontainer; leave the runtime as orchestration + lifecycle. - Lazy-construct the runtime singleton — move construction +
atexitregistration intoget_open_telemetry_runtime()to drop the import side effect. - Per-run progress gauge label — only if overlapping creates become a supported scenario (metric-schema change).
The Warnings above are small enough to fix in this PR rather than defer (the accepts_scheduler_event Protocol declaration, the swallowed-exception logging, and the otel_metrics_port=None docstring), as are the DRY _metrics_sink() accessor and the getattr cleanup.
Verdict
Needs changes — nothing here is a hard blocker on the happy path, but before merge it's worth: (1) getting explicit sign-off on default-on telemetry (or flipping to opt-in), and (2) closing the still-open P2 lease-leak window in model_request_executor. The correlation-propagation question is a "confirm or add a test" item.
On the design side, the low-risk Warnings worth folding into this PR are the hot-path/preview build-then-drop (accepts_request_event gate), the Any typing (TYPE_CHECKING), the phantom-protocol method (declare accepts_scheduler_event), the swallowed-exception logging, and the otel_metrics_port=None docstring — plus the small _metrics_sink()/getattr cleanups. The god-object split, lazy singleton, per-run progress label, and especially the three inconsistent emission seams are the larger items better tracked as follow-up issues than jammed into this PR.
ruff check and ruff format --check pass clean on all changed source files.
This review was generated by an AI assistant.
andreatgretel
left a comment
There was a problem hiding this comment.
One inline question.
|
Implemented the requested review follow-ups in What changed:
Validation:
The PR is conflict-free and mergeable. Please re-review/clear the prior changes-requested state when convenient. |
Signed-off-by: Eric W. Tramel <eric.tramel@gmail.com>
Expose lifecycle-aware dataset progress and paired active model request metrics. Add standard per-model GenAI attributes so external dashboards can track concurrent work without task-level OTel coupling. Signed-off-by: Eric W. Tramel <eric.tramel@gmail.com>
Signed-off-by: Eric W. Tramel <eric.tramel@gmail.com>
Signed-off-by: Eric W. Tramel <eric.tramel@gmail.com>
- release request leases when started-event emission is interrupted - skip request events that the configured sink does not consume - preserve scheduler terminal diagnostics and classify early shutdown - bound exported model labels and strengthen correlation coverage Signed-off-by: Eric W. Tramel <eric.tramel@gmail.com>
Signed-off-by: Eric W. Tramel <eric.tramel@gmail.com>
8b63e67 to
eeae00e
Compare
|
Rebased this PR onto current Meaningful rebase resolution:
Validation on the final tree:
The prior lease-cleanup P2 thread is outdated and remains covered by the current fix/regressions. The PR is conflict-free and GitHub reports it as mergeable; the only remaining merge gate is the existing |
| elif self._port != port and not self._active_run_ids: | ||
| old_server, old_thread = self._rebind(port) | ||
| elif self._port != port: | ||
| logger.warning( |
There was a problem hiding this comment.
There's a second deadlock in the same family as the P1 that was fixed: this logger.warning runs while holding self._lock, and so do the logger.info in _rebind and the warning in _finish_active_requests. The trouble is _MetricLogHandler sits on the data_designer logger, and Python's logging holds a per-handler lock around emit(), which calls record_log, which takes self._lock. So a normally-logging engine thread takes handler lock then runtime lock, while these paths take runtime lock then handler lock. Two threads interleaving those orders hang each other, and since one of them keeps the handler lock, every data_designer log call in the process hangs after that too, and the process can't even exit (logging.shutdown() blocks at interpreter exit). Concurrent creates open the window, e.g. a second create with a different port hits this exact line while the first one's workers are logging.
Claude Code and Codex both reproduced this with two threads. The fix is the same shape as the P1: never log while holding the lock (capture the message, log after release), and ideally make record_log's gate a lock-free read so the logging path never touches the runtime lock at all.
There was a problem hiding this comment.
Fixed in 9aee1dde. The three runtime-owned logging paths now collect messages/errors while _lock is held and call logger.* only after the outer lock is released: initial/reused bind logging, the conflicting-port warning, and the active-request reconciliation warning. I kept record_log under the lock because its gate must remain ordered with teardown; making that read lock-free would reintroduce stale post-teardown metrics. I added a deterministic regression covering all three callbacks and reproduced the real handler-lock inversion; after this fix, handler-blocked logging no longer holds the runtime lock. The focused OTel suite has 31 passing tests and the full suite has 3,940 passing tests (one existing skip).
heads up: #800 landed on main about half an hour after this branch's last commit, and it wires One more thing worth flagging to the #800 folks: this PR moves |
| return max(0, value) if isinstance(value, int) else 0 | ||
|
|
||
|
|
||
| def _bounded_attribute(value: object) -> str: |
There was a problem hiding this comment.
Codex flagged this one during cross-review: stripping characters before truncating isn't collision-resistant, so distinct models can merge into one Prometheus series. llama3:8b and llama38b both normalize to llama38b, and two model IDs that differ only past character 128 collide too. Since these labels define the series for the active-request gauge and the duration histogram, the dashboards would silently blend latency and concurrency from different endpoints. Prometheus and OTel allow arbitrary strings as label values, so I think the length cap alone gives you the bounding, maybe with a stable hash suffix appended whenever the value had to be modified.
There was a problem hiding this comment.
Fixed in 9aee1dde. Short provider/model identities are now preserved verbatim, so punctuation cannot collapse distinct series. Values over 128 characters retain a readable prefix plus a stable 16-hex SHA-256 suffix, preserving the existing length bound without same-prefix collisions. This is applied to both gen_ai.provider.name and gen_ai.request.model, with regressions for llama3:8b versus llama38b and for two long same-prefix model IDs. The focused OTel suite has 31 passing tests and the full suite has 3,940 passing tests (one existing skip).
- emit runtime logs only after releasing the state lock - preserve distinct provider and model metric identities Signed-off-by: Eric W. Tramel <eric.tramel@gmail.com>
|
@andreatgretel — points 1 and 2 are addressed in
I ran the requested seven-persona review (elegance, idiomatic Python, correctness, completeness, security, YAGNI, and scope). No actionable findings were accepted. The only suggestion was a P4 YAGNI proposal to return unbounded raw label values; I rejected it because the PR explicitly guarantees bounded metric attributes, and hash-suffixed truncation preserves that contract without merging distinct identities. The patch stays scoped to the OTel runtime and its tests—no dependency, API, or layering changes. Validation is clean: |
📋 Summary
What. Adds default, pull-based OpenTelemetry metrics for
DataDesigner.create()on a loopback Prometheus endpoint controlled byRunConfig.Why. Gives operators current create progress, throughput, scheduler outcomes, model activity, request completion/latency, errors, and safe log-volume signals without making task code depend on OpenTelemetry or adding parallel instrumentation paths.
The interface layer owns a private OTel meter provider, registry, and HTTP listener. Existing generic engine scheduler and request-event seams feed the exporter, while a metric-only logging handler tees severity counts without exporting log bodies. Telemetry failures are contained and cannot change create results.
🔗 Related Issue
None.
🔄 Changes
RunConfig.otel_metrics_port, defaulting to loopback port9464;Nonedisables telemetry for an invocation./metrics:data_designer.create.durationdata_designer.dataset.recordsdata_designer.dataset.progressdata_designer.scheduler.eventsdata_designer.model.request.activegen_ai.client.operation.durationdata_designer.log.recordsgen_ai.operation.name,gen_ai.provider.name, andgen_ai.request.modelattributes.mainwith the OTel sink through an exception-isolated fan-out, so both observers receive events when enabled.📖 Usage
The default create path starts
http://127.0.0.1:9464/metrics:Choose another loopback port or disable telemetry:
Prometheus can scrape
127.0.0.1:9464at/metrics. The listener is process-wide and remains available after a create job so final cumulative metrics can be collected. Generated-record totals and progress advance at durable row-group checkpoints; the active-request gauge and request-duration count provide live activity between checkpoints.📊 External dashboard validation
A local external Prometheus/Grafana stack scraped the listener every 2 seconds and rendered current listener health, active jobs, durable progress, generated records, active requests by exact model, attempts/second, p95 latency, scheduler totals, failures, and log volume.
The end-to-end demonstration generated 512 records through 1,536 logical model tasks using the
nvidia-internalprovider and three non-8B models, each configured withmax_tokens=1024:nvidia/openai/gpt-oss-20bnvidia/nvidia/nemotron-nano-31b-v3nvidia/openai/gpt-oss-120bAll 1,536 logical tasks completed successfully. The external collector observed two transient
internal_serverattempts on the 120B model, two matchingtask.retry_deferredscheduler events, and final job success. Dashboard queries use raw counters for event/failure totals, short rate windows aligned with the 2-second scrape interval, a lifecycle-aware progress gauge, and model-separated GenAI series so the panels represent current state instead of long-window residual activity.The original POC capture is retained below; the validated dashboard now includes the live-state and 2-second refresh refinements described above.
📝 Changelog
RunConfigport/disable option.🧪 Testing
make check-all— passedmake test— 3,940 passed, 1 existing skipFocused scheduler, request-admission, builder, interface, and OpenTelemetry rebase suite — 410 passed
Seven-persona delegated review of the latest deadlock/label patch — no actionable findings; one P4 simplification suggestion was rejected because it would remove the documented 128-character attribute bound
Rebase-specific local review across correctness, API compatibility, and test coverage — no actionable findings
Deterministic delayed-event, overlapping-teardown, unlocked-rebind, handler-lock inversion, and provider/model identity-collision regressions — passed
Isolated package-install suites — passed during feature validation
Import-performance and lazy-import checks — passed
Fern validation — 0 errors, 1 existing warning
Live
nvidia-internalmulti-model run — 512 records and 1,536 logical tasks completed successfully while an external Prometheus/Grafana stack observed executionmake testpassesUnit tests added/updated
E2E behavior validated
👀 Review areas
✅ Checklist
Description updated with AI