cmpnd 0.26.0__tar.gz → 0.26.1__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.
- {cmpnd-0.26.0 → cmpnd-0.26.1}/PKG-INFO +1 -1
- {cmpnd-0.26.0 → cmpnd-0.26.1}/docs/sdk-instrumentation.md +6 -2
- {cmpnd-0.26.0 → cmpnd-0.26.1}/ingest/cmpnd_ingest/exporter_http.py +23 -25
- {cmpnd-0.26.0 → cmpnd-0.26.1}/pyproject.toml +1 -1
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_exporter.py +79 -10
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_exporter_real.py +23 -12
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_ingest_second_producer.py +5 -6
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_integration.py +9 -15
- cmpnd-0.26.1/tests/test_span_sent_once.py +178 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/uv.lock +1 -1
- {cmpnd-0.26.0 → cmpnd-0.26.1}/.gitignore +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/AGENTS.md +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/CLAUDE.md +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/README.md +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/__init__.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/_assessments.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/_authored_import.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/_exporter_pool.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/_gepa_patch.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/_http.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/_program_patch.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/_rlm_patch.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/_timespec.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/_traces.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/_valsets.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/authored.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/callback.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/__init__.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/api_client.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/__init__.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/admin.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/assessments.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/auth.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/datasets.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/deployments.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/evals.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/login.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/optimizations.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/org.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/programs.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/provider_credentials.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/signatures.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/tool_servers.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/top.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/traces.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/commands/valsets.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/credentials.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/deprecation.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/pagination.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/shell.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/snippet.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/trace_render.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/tree_help.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/cli/verbs.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/client.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/configuration.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/context.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/dataset_sync.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/datasets.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/decorators.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/deployment.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/eval_handler.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/execution.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/exporter.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/exporter_http.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/helpers.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/identity.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/imaging.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/ir/__init__.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/ir/decode.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/ir/encode.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/ir/hash.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/ir/program.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/ir/reflection.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/ir/types.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/ir/xxh64.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/mcp_servers.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/models.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/optimization.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/optimizers/__init__.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/optimizers/gepa.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/optimizers/gepa_callback.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/optimizers/resume.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/optimizers/tracker.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/packaging.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/retry.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/cmpnd/traces.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/docs/2026-04-02-code-review.md +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/docs/cli.md +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/docs/client.md +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/docs/custom-proposer.md +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/docs/plans/2026-04-05-train-val-dataset-capture-design.md +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/docs/plans/2026-04-05-train-val-dataset-capture.md +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/docs/reading-traces.md +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/examples/generalizing_proposer.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/examples/local_ollama.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/ingest/LINEAGE.md +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/ingest/README.md +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/ingest/cmpnd_ingest/__init__.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/ingest/cmpnd_ingest/bodies.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/ingest/cmpnd_ingest/protocols.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/ingest/cmpnd_ingest/py.typed +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/ingest/cmpnd_ingest/request_log.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/ingest/cmpnd_ingest/retry.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/ingest/pyproject.toml +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/scripts/audit-sdist.sh +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/scripts/capture_review_run.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/scripts/check_release_artifacts.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/scripts/fixtures/review_run.json.gz +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/scripts/gepa_resume_bug.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/scripts/require_version_bump.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/scripts/seed_review_run.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/scripts/test-in-linux.sh +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/stubs/dspy/__init__.pyi +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/stubs/dspy/primitives/__init__.pyi +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/stubs/dspy/primitives/prediction.pyi +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/stubs/dspy/utils/__init__.pyi +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/stubs/dspy/utils/callback.pyi +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/__init__.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/_exporter_helpers.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/_fake_clock.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/_identity_helpers.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/conftest.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/__init__.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/committee_task.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/conftest.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/data/committee_train.ndjson.gz +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/data/committee_val.ndjson.gz +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/fake_lm.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/helpers.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/target_capabilities.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_assessments_cli.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_capture_prompts.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_composite_module.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_dataset_auto_creation.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_deploy_execute.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_deploy_programs.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_edge_cases.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_example_hash.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_gepa_optimization.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_interpreter_spans.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_lm_accuracy.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_module_tracing.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_optimization_lineage.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_optimization_metric_identity.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_optimize_failures.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_optimize_loop.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_optimize_review_committee.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_optimize_server_owned_completion.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_parallel_export.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_project_attribution.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_react_tool_spans.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_save_load.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_trace_structure.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_traces_read.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_user_attribution.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/e2e/test_valsets_cli.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/01_predict_qa.json +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/02_predict_renamed_field.json +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/03_chain_of_thought_qa.json +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/04_predict_richer_types.json +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/05_composite_two_predicts.json +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/06_multichaincomparison.json +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/07_program_of_thought.json +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/08_opaque_module.json +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/09_predict_pydantic_field.json +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/10_react.json +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/11_refine.json +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/12_retrieve.json +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/13_knn.json +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/14_parallel.json +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/identity_vectors/README.md +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/ingest_conformance.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/integration/__init__.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/integration/conftest.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/integration/hosted_mcp_tool_optimize_probe.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/integration/hosted_optimize_probe.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/integration/optimization_run_probe.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/integration/test_cli_deploy_smoketest.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/integration/test_logs_renders_traces.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/integration/test_optimization_run.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/integration/test_optimizing_progress.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/integration/test_unhappy_path.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/load/__init__.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/load/__main__.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/load/drain.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/load/ingest.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/load/latency_transport.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/load/rlm_shape.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/load/sweep.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/load/test_rlm_shape.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/__init__.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/conftest.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/seed_parity.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_auth.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_datasets.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_deployments.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_evals.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_health.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_modules.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_optimizations.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_programs.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_signatures.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_trace_streaming.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/parity/test_traces.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/__init__.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/_provider.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/_reliability.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/_result.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/mint_bedrock_token.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_deploy_programs.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_multi_client_stress.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_optimize_client_committee.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_optimize_loop.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_optimize_loop_wasm.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_optimize_stress.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_provider_roles.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_real_provider_twins.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_reliability.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_reliability_classify.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_rlm_wasm.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/stress/test_trace_search.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_assessments_api.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_authored.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_callback.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_candidate_lineage.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/__init__.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/conftest.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_admin.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_api_client.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_assessments.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_auth.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_credentials.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_datasets.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_deployments.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_evals.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_login.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_main.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_optimizations.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_org.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_pagination.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_parity.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_programs.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_provider_credentials.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_schema_sample.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_shell.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_signatures.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_tool_servers.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_top.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_traces.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_traces_search_wire.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_tree_help.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cli/test_valsets.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_client.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_clustering/__init__.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_clustering/conftest.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_clustering/test_edge_cases.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_clustering/test_hash_consistency.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_clustering/test_type_conversion.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_cmp551_grouping_identity.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_configuration.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_context.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_context_propagation.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_dataset_polling.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_dataset_sync.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_datasets.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_decorators.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_deploy.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_deploy_mcp_tool_references.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_eval.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_eval_example_attribution.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_eval_start.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_eval_trace_grouping.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_exception_paths.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_execute.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_exporter_queue.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_fake_lm.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_gepa_callback.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_gepa_integration.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_gepa_resume.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_identity.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_imaging.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_import_weight.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_ingest_conformance.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_interpreter_callback.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_ir_encoder_properties.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_ir_golden_vectors.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_ir_roundtrip.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_lm_usage_shapes.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_mcp_tools.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_models.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_optimization_progress.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_optimization_start.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_optimize.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_optimize_budget.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_optimize_lift.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_optimize_lms.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_optimize_proposer.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_optimize_reflection_lm.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_optimize_terminal_failure.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_packaging.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_packaging_callable_source.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_packaging_mcp_tools.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_packaging_program_source.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_poll_until.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_program_lineage_persist.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_project_payload.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_reflector_properties.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_reflector_smoke.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_repro_reactv2_bugs.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_require_version_bump.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_retry.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_reversible_failures.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_rlm_patch.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_schemas.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_seed_review_run.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_trace_render.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_traces_read.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_tracked_gepa.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_tracker_enrich.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_truncation.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_valsets_api.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/tests/test_xxh64.py +0 -0
- {cmpnd-0.26.0 → cmpnd-0.26.1}/todo.md +0 -0
|
@@ -412,9 +412,13 @@ Every session opened this way lives on one background thread and loop
|
|
|
412
412
|
binds to the loop it was opened on, and `dspy.Tool.__call__` runs an async tool
|
|
413
413
|
body under `asyncio.run` — a fresh loop — whenever none is already running.
|
|
414
414
|
|
|
415
|
-
###
|
|
415
|
+
### A span is sent exactly once
|
|
416
416
|
|
|
417
|
-
|
|
417
|
+
`_flush_batch` picks one route per span and no producer can make both happen. When a flush carries a trace's own `trace` item, that trace's loose `span` items ride its body (`POST /api/v1/traces`) and the spans route is not called for them; a span is folded in only when the body does not already carry its `span_id`, because `TraceRecord.to_api_dict` already serialises the trace's `_batch_spans`. A span whose trace is not in the flush — an orphan, or a span the callback streams while its trace is still running — goes to `/api/v1/traces/:id/spans` and nowhere else.
|
|
418
|
+
|
|
419
|
+
The flush is the only place that decides, and it decides per flush with no buffer carried between them: both routes write the same trace's rows, so a span on both at once is two concurrent writers over one trace, and double upload for whoever pays for the bandwidth.
|
|
420
|
+
|
|
421
|
+
Two producers put a `span` item and its trace's `trace` item in the same flush. `cmpnd.start_span` / `@cmpnd.trace` export the span they close directly, and the callback's `_should_stream_span` streams a span whose trace it has no record of. Neither has to know about the other.
|
|
418
422
|
|
|
419
423
|
### In-progress trace starts (CMP-257)
|
|
420
424
|
|
|
@@ -306,10 +306,6 @@ class BatchExporter:
|
|
|
306
306
|
# fresh lock mid-restart would let a second thread in immediately.
|
|
307
307
|
self._restart_lock = threading.Lock()
|
|
308
308
|
|
|
309
|
-
# Buffer spans by trace_id until trace arrives
|
|
310
|
-
self._pending_spans: dict[str, list[dict[str, Any]]] = {}
|
|
311
|
-
self._pending_spans_lock = threading.Lock()
|
|
312
|
-
|
|
313
309
|
# Stats (guarded by _stats_lock for thread-safe parallel dispatch)
|
|
314
310
|
self._exported_count = 0
|
|
315
311
|
self._dropped_count = 0
|
|
@@ -438,8 +434,6 @@ class BatchExporter:
|
|
|
438
434
|
|
|
439
435
|
self._sync_client_lock = threading.Lock()
|
|
440
436
|
self._stats_lock = threading.Lock()
|
|
441
|
-
self._pending_spans_lock = threading.Lock()
|
|
442
|
-
self._pending_spans = {}
|
|
443
437
|
|
|
444
438
|
self._flush_cond = threading.Condition()
|
|
445
439
|
self._flush_requested_gen = 0
|
|
@@ -1138,7 +1132,6 @@ class BatchExporter:
|
|
|
1138
1132
|
trace_starts: list[tuple[str, dict[str, Any]]] = []
|
|
1139
1133
|
trace_finalizes: list[tuple[str, dict[str, Any]]] = []
|
|
1140
1134
|
spans_by_trace: dict[str, list[dict[str, Any]]] = {}
|
|
1141
|
-
new_spans: dict[str, list[dict[str, Any]]] = {}
|
|
1142
1135
|
|
|
1143
1136
|
for item in batch:
|
|
1144
1137
|
if item.item_type == "trace":
|
|
@@ -1170,9 +1163,6 @@ class BatchExporter:
|
|
|
1170
1163
|
logger.warning("Dropping span without trace_id")
|
|
1171
1164
|
continue
|
|
1172
1165
|
spans_by_trace.setdefault(trace_id, []).append(span_dict)
|
|
1173
|
-
if trace_id not in new_spans:
|
|
1174
|
-
new_spans[trace_id] = []
|
|
1175
|
-
new_spans[trace_id].append(span_dict)
|
|
1176
1166
|
|
|
1177
1167
|
# Coalesce start + batch within one flush: if a trace's full
|
|
1178
1168
|
# batch trace is in this same flush, sending it upserts the row to its
|
|
@@ -1185,23 +1175,31 @@ class BatchExporter:
|
|
|
1185
1175
|
batched_ids = {str(t.get("trace_id")) for t in traces_to_send}
|
|
1186
1176
|
trace_starts = [pair for pair in trace_starts if pair[0] not in batched_ids]
|
|
1187
1177
|
|
|
1188
|
-
#
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
#
|
|
1178
|
+
# A span is sent exactly once. When its trace ships in this same flush it
|
|
1179
|
+
# rides that trace's body and is taken off the spans route; otherwise the
|
|
1180
|
+
# spans route is the only way it reaches the server. This is the one
|
|
1181
|
+
# place that decides, so no producer can make both happen.
|
|
1182
|
+
#
|
|
1183
|
+
# Sending it twice is not merely wasteful upload: POST /traces and
|
|
1184
|
+
# POST /traces/{id}/spans write the same trace's rows, so a span on both
|
|
1185
|
+
# routes at once is two concurrent writers racing over one trace — the
|
|
1186
|
+
# shape the server has to be lock-ordered against. Not emitting the pair
|
|
1187
|
+
# is the SDK's half of that.
|
|
1188
|
+
#
|
|
1189
|
+
# A trace's own spans are already in its body (TraceRecord.to_api_dict
|
|
1190
|
+
# serialises them), so a span is folded in only when the body does not
|
|
1191
|
+
# already carry its span_id.
|
|
1196
1192
|
for trace_dict in traces_to_send:
|
|
1197
1193
|
trace_id = str(trace_dict.get("trace_id"))
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1194
|
+
body_spans = list(trace_dict.get("spans") or [])
|
|
1195
|
+
carried = {str(span.get("span_id")) for span in body_spans}
|
|
1196
|
+
for span_dict in spans_by_trace.pop(trace_id, []):
|
|
1197
|
+
span_id = str(span_dict.get("span_id"))
|
|
1198
|
+
if span_id in carried:
|
|
1199
|
+
continue
|
|
1200
|
+
carried.add(span_id)
|
|
1201
|
+
body_spans.append(span_dict)
|
|
1202
|
+
trace_dict["spans"] = body_spans
|
|
1205
1203
|
|
|
1206
1204
|
# --- Phase 2: Parallel dispatch ---
|
|
1207
1205
|
|
|
@@ -5,7 +5,7 @@ name = "cmpnd"
|
|
|
5
5
|
# it's new) — no git tags. Bump with `uv version --bump {minor,patch}` or by
|
|
6
6
|
# hand. Runtime `cmpnd.__version__` reads installed metadata, so it tracks
|
|
7
7
|
# this automatically. See docs/plans/2026-07-02-sdk-pypi-tag-free-publish.md.
|
|
8
|
-
version = "0.26.
|
|
8
|
+
version = "0.26.1"
|
|
9
9
|
description = "DSPy observability and deployment SDK for cmpnd"
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
requires-python = ">=3.10"
|
|
@@ -339,25 +339,92 @@ def test_exporter_sends_trace_finalize():
|
|
|
339
339
|
assert payload["total_tokens"] == 100
|
|
340
340
|
|
|
341
341
|
|
|
342
|
-
|
|
343
|
-
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
# A span is sent exactly once
|
|
344
|
+
# ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_span_rides_its_trace_body_and_skips_the_spans_route():
|
|
348
|
+
"""A span flushed alongside its own trace ships in that trace's body, and the
|
|
349
|
+
spans route is not called for it — the two server paths write the same trace,
|
|
350
|
+
so one span must not arrive on both at once."""
|
|
344
351
|
capture = RequestCapture()
|
|
345
352
|
exporter = _make_exporter(capture)
|
|
346
353
|
|
|
347
354
|
tid = uuid4()
|
|
348
355
|
trace = _make_trace(trace_id=tid)
|
|
349
|
-
|
|
350
|
-
# Simulate old path: manually buffer a span
|
|
351
356
|
span = _make_span(trace_id=tid)
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
357
|
+
batch = [
|
|
358
|
+
ExportItem(item_type="trace", data=trace, timestamp=0.0),
|
|
359
|
+
ExportItem(item_type="span", data=span, timestamp=0.0),
|
|
360
|
+
]
|
|
355
361
|
exporter._flush_batch(batch)
|
|
356
362
|
|
|
363
|
+
assert capture.requests_to(f"/api/v1/traces/{tid}/spans") == []
|
|
357
364
|
reqs = capture.requests_to("/api/v1/traces")
|
|
358
365
|
assert len(reqs) == 1
|
|
359
366
|
payload = decode_body(reqs[0])
|
|
360
|
-
assert
|
|
367
|
+
assert [s["span_id"] for s in payload["spans"]] == [str(span.span_id)]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def test_span_without_its_trace_in_the_flush_goes_to_the_spans_route_once():
|
|
371
|
+
"""No trace item for the span's trace means the spans route is the only way
|
|
372
|
+
it reaches the server — and it goes there exactly once."""
|
|
373
|
+
capture = RequestCapture()
|
|
374
|
+
exporter = _make_exporter(capture)
|
|
375
|
+
|
|
376
|
+
tid = uuid4()
|
|
377
|
+
span = _make_span(trace_id=tid)
|
|
378
|
+
other = _make_trace()
|
|
379
|
+
batch = [
|
|
380
|
+
ExportItem(item_type="span", data=span, timestamp=0.0),
|
|
381
|
+
ExportItem(item_type="trace", data=other, timestamp=0.0),
|
|
382
|
+
]
|
|
383
|
+
exporter._flush_batch(batch)
|
|
384
|
+
|
|
385
|
+
reqs = capture.requests_to(f"/api/v1/traces/{tid}/spans")
|
|
386
|
+
assert len(reqs) == 1
|
|
387
|
+
payload = decode_body(reqs[0])
|
|
388
|
+
assert [s["span_id"] for s in payload["spans"]] == [str(span.span_id)]
|
|
389
|
+
# The unrelated trace ships with an empty body, not with someone else's span.
|
|
390
|
+
assert decode_body(capture.requests_to("/api/v1/traces")[0])["spans"] == []
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def test_span_already_in_the_trace_body_is_not_appended_twice():
|
|
394
|
+
"""A trace carries its own spans by construction. A span item naming one of
|
|
395
|
+
them adds nothing — the body keeps a single copy."""
|
|
396
|
+
capture = RequestCapture()
|
|
397
|
+
exporter = _make_exporter(capture)
|
|
398
|
+
|
|
399
|
+
tid = uuid4()
|
|
400
|
+
span = _make_span(trace_id=tid)
|
|
401
|
+
trace = _make_trace(trace_id=tid)
|
|
402
|
+
trace._batch_spans = [span]
|
|
403
|
+
batch = [
|
|
404
|
+
ExportItem(item_type="trace", data=trace, timestamp=0.0),
|
|
405
|
+
ExportItem(item_type="span", data=span, timestamp=0.0),
|
|
406
|
+
]
|
|
407
|
+
exporter._flush_batch(batch)
|
|
408
|
+
|
|
409
|
+
assert capture.requests_to(f"/api/v1/traces/{tid}/spans") == []
|
|
410
|
+
payload = decode_body(capture.requests_to("/api/v1/traces")[0])
|
|
411
|
+
assert [s["span_id"] for s in payload["spans"]] == [str(span.span_id)]
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def test_span_of_an_earlier_flush_is_not_re_attached_to_a_later_trace():
|
|
415
|
+
"""A span dispatched on the spans route in one flush is delivered. A later
|
|
416
|
+
flush carrying its trace must not send it a second time in the body."""
|
|
417
|
+
capture = RequestCapture()
|
|
418
|
+
exporter = _make_exporter(capture)
|
|
419
|
+
|
|
420
|
+
tid = uuid4()
|
|
421
|
+
span = _make_span(trace_id=tid)
|
|
422
|
+
exporter._flush_batch([ExportItem(item_type="span", data=span, timestamp=0.0)])
|
|
423
|
+
assert len(capture.requests_to(f"/api/v1/traces/{tid}/spans")) == 1
|
|
424
|
+
|
|
425
|
+
exporter._flush_batch([ExportItem(item_type="trace", data=_make_trace(trace_id=tid), timestamp=0.0)])
|
|
426
|
+
payload = decode_body(capture.requests_to("/api/v1/traces")[0])
|
|
427
|
+
assert payload["spans"] == []
|
|
361
428
|
|
|
362
429
|
|
|
363
430
|
# ---------------------------------------------------------------------------
|
|
@@ -747,8 +814,10 @@ def test_full_trace_spans_counted_in_confirmation(caplog):
|
|
|
747
814
|
|
|
748
815
|
tid = uuid4()
|
|
749
816
|
trace = _make_trace(trace_id=tid)
|
|
750
|
-
|
|
751
|
-
|
|
817
|
+
batch = [
|
|
818
|
+
ExportItem(item_type="trace", data=trace, timestamp=0.0),
|
|
819
|
+
ExportItem(item_type="span", data=_make_span(trace_id=tid), timestamp=0.0),
|
|
820
|
+
]
|
|
752
821
|
|
|
753
822
|
with caplog.at_level(logging.INFO, logger="cmpnd.exporter"):
|
|
754
823
|
exporter._flush_batch(batch)
|
|
@@ -312,29 +312,40 @@ class TestErrorHandling:
|
|
|
312
312
|
# ---------------------------------------------------------------------------
|
|
313
313
|
|
|
314
314
|
|
|
315
|
-
class
|
|
316
|
-
"""
|
|
315
|
+
class TestSpanRouting:
|
|
316
|
+
"""A span takes one route to the server, decided by the flush it lands in."""
|
|
317
317
|
|
|
318
|
-
def
|
|
318
|
+
def test_span_flushed_before_its_trace_is_not_sent_again_with_it(self):
|
|
319
|
+
"""The spans route already delivered it, so the trace that follows carries
|
|
320
|
+
only the spans of its own body."""
|
|
319
321
|
capture = RequestCapture()
|
|
320
322
|
exporter = _make_exporter(capture)
|
|
321
323
|
tid = uuid4()
|
|
322
324
|
|
|
323
|
-
|
|
324
|
-
span = _make_span(trace_id=tid)
|
|
325
|
-
exporter.export_span(span)
|
|
326
|
-
# Flush spans first to populate pending buffer
|
|
325
|
+
exporter.export_span(_make_span(trace_id=tid))
|
|
327
326
|
exporter._flush_batch(exporter._drain_pending())
|
|
327
|
+
assert len(capture.payloads_to(f"/api/v1/traces/{tid}/spans")) == 1
|
|
328
328
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
329
|
+
exporter.export_trace(_make_trace(trace_id=tid))
|
|
330
|
+
exporter._flush_batch(exporter._drain_pending())
|
|
331
|
+
|
|
332
|
+
trace_payloads = capture.payloads_to("/api/v1/traces")
|
|
333
|
+
assert len(trace_payloads) == 1
|
|
334
|
+
assert trace_payloads[0]["spans"] == []
|
|
335
|
+
|
|
336
|
+
def test_span_flushed_with_its_trace_rides_the_trace_body(self):
|
|
337
|
+
capture = RequestCapture()
|
|
338
|
+
exporter = _make_exporter(capture)
|
|
339
|
+
tid = uuid4()
|
|
340
|
+
|
|
341
|
+
exporter.export_span(_make_span(trace_id=tid))
|
|
342
|
+
exporter.export_trace(_make_trace(trace_id=tid))
|
|
332
343
|
exporter._flush_batch(exporter._drain_pending())
|
|
333
344
|
|
|
334
|
-
|
|
345
|
+
assert capture.payloads_to(f"/api/v1/traces/{tid}/spans") == []
|
|
335
346
|
trace_payloads = capture.payloads_to("/api/v1/traces")
|
|
336
347
|
assert len(trace_payloads) == 1
|
|
337
|
-
assert len(trace_payloads[0]["spans"])
|
|
348
|
+
assert len(trace_payloads[0]["spans"]) == 1
|
|
338
349
|
|
|
339
350
|
|
|
340
351
|
# ---------------------------------------------------------------------------
|
|
@@ -456,10 +456,10 @@ def test_a_dict_trace_body_posts_the_bytes_the_typed_builder_would_have(spec: In
|
|
|
456
456
|
|
|
457
457
|
|
|
458
458
|
def test_a_dict_trace_body_collects_the_dict_spans_that_claim_it() -> None:
|
|
459
|
-
"""A span
|
|
460
|
-
|
|
461
|
-
hands spans over as it goes still sends
|
|
462
|
-
read out of the span's own body, so `SpanBody` must not lose it."""
|
|
459
|
+
"""A span flushed alongside the trace that claims it rides that trace's body and
|
|
460
|
+
takes no second route — the same coalescing the typed path gets, so a dict-backed
|
|
461
|
+
producer that hands spans over as it goes still sends each span once. Which trace a
|
|
462
|
+
span belongs to is read out of the span's own body, so `SpanBody` must not lose it."""
|
|
463
463
|
builder = _trace()
|
|
464
464
|
transport = CapturingTransport()
|
|
465
465
|
|
|
@@ -468,8 +468,7 @@ def test_a_dict_trace_body_collects_the_dict_spans_that_claim_it() -> None:
|
|
|
468
468
|
exporter.export_trace(TraceBody(builder.to_api_dict()))
|
|
469
469
|
assert exporter.flush(timeout=10)
|
|
470
470
|
|
|
471
|
-
|
|
472
|
-
assert [r.path for r in spans_posts] == [f"/api/v1/traces/{builder.trace_id}/spans"]
|
|
471
|
+
assert [r for r in transport.requests if r.path.endswith("/spans")] == []
|
|
473
472
|
traces_post = next(r for r in transport.requests if r.path == "/api/v1/traces")
|
|
474
473
|
assert isinstance(traces_post.body, dict)
|
|
475
474
|
assert len(traces_post.body["spans"]) == 1
|
|
@@ -304,12 +304,10 @@ class TestExporterIntegration:
|
|
|
304
304
|
shutdown_exporter()
|
|
305
305
|
dspy.configure(callbacks=[])
|
|
306
306
|
|
|
307
|
-
def
|
|
308
|
-
"""
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
path) AND buffered for the old trace path. So we expect 2 POST calls:
|
|
312
|
-
one to /api/v1/traces/:id/spans and one to /api/v1/traces.
|
|
307
|
+
def test_spans_ride_the_trace_they_are_flushed_with(self):
|
|
308
|
+
"""A span whose trace is in the same flush ships inside that trace's body,
|
|
309
|
+
and the per-trace spans route is not called for it: the two server paths
|
|
310
|
+
write the same trace's rows, so one span must not travel both at once.
|
|
313
311
|
"""
|
|
314
312
|
from datetime import datetime, timezone
|
|
315
313
|
from uuid import uuid4
|
|
@@ -368,10 +366,10 @@ class TestExporterIntegration:
|
|
|
368
366
|
|
|
369
367
|
exporter._flush_batch(items)
|
|
370
368
|
|
|
371
|
-
#
|
|
372
|
-
#
|
|
373
|
-
#
|
|
374
|
-
assert mock_client.request.call_count ==
|
|
369
|
+
# One POST carries the whole trace. Bodies ship gzipped through
|
|
370
|
+
# client.request(method, path, content=…), so decode the
|
|
371
|
+
# content kwarg to inspect them.
|
|
372
|
+
assert mock_client.request.call_count == 1
|
|
375
373
|
|
|
376
374
|
# Identify calls by URL (positional args: method, path) and decode body.
|
|
377
375
|
calls_by_url = {}
|
|
@@ -380,10 +378,6 @@ class TestExporterIntegration:
|
|
|
380
378
|
body = json.loads(gzip.decompress(c[1]["content"]))
|
|
381
379
|
calls_by_url[url] = body
|
|
382
380
|
|
|
383
|
-
|
|
381
|
+
assert f"/api/v1/traces/{trace_id}/spans" not in calls_by_url
|
|
384
382
|
trace_data = calls_by_url["/api/v1/traces"]
|
|
385
383
|
assert len(trace_data["spans"]) == 2
|
|
386
|
-
|
|
387
|
-
# Streaming spans POST
|
|
388
|
-
span_url = f"/api/v1/traces/{trace_id}/spans"
|
|
389
|
-
assert len(calls_by_url[span_url]["spans"]) == 2
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Every span the SDK produces reaches the server exactly once.
|
|
2
|
+
|
|
3
|
+
A span has two routes to the server: ``POST /api/v1/traces/{id}/spans``, and its
|
|
4
|
+
trace's own body on ``POST /api/v1/traces``. Both write the same trace's rows, so
|
|
5
|
+
a span taking both at once is two concurrent writers over one trace, and it is
|
|
6
|
+
double upload for whoever is paying for the bandwidth. The exporter's flush is
|
|
7
|
+
the one place that picks a route, so these tests drive the real callback into a
|
|
8
|
+
real ``BatchExporter`` over a mock transport and count what the wire carries.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
from unittest import mock
|
|
15
|
+
|
|
16
|
+
import dspy
|
|
17
|
+
import httpx
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
import cmpnd
|
|
21
|
+
from cmpnd.configuration import Config
|
|
22
|
+
from cmpnd.exporter import BatchExporter
|
|
23
|
+
from tests._exporter_helpers import decode_body
|
|
24
|
+
from tests.e2e import fake_lm
|
|
25
|
+
from tests.test_eval_trace_grouping import ( # noqa: F401 (autouse fixtures)
|
|
26
|
+
CapitalQA,
|
|
27
|
+
RecordingExporter,
|
|
28
|
+
_fake_provider,
|
|
29
|
+
_sdk_configured,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
pytest.importorskip("dspy")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _Wire:
|
|
36
|
+
"""Captures what the exporter puts on the wire."""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self.requests: list[httpx.Request] = []
|
|
40
|
+
|
|
41
|
+
def handler(self, request: httpx.Request) -> httpx.Response:
|
|
42
|
+
self.requests.append(request)
|
|
43
|
+
return httpx.Response(200, json={"status": "ok"})
|
|
44
|
+
|
|
45
|
+
def span_ids_by_route(self) -> dict[str, list[str]]:
|
|
46
|
+
"""span_id -> the request paths that carried it, one entry per carry."""
|
|
47
|
+
seen: dict[str, list[str]] = {}
|
|
48
|
+
for request in self.requests:
|
|
49
|
+
path = request.url.path
|
|
50
|
+
payload = decode_body(request)
|
|
51
|
+
spans: list[dict[str, Any]] = []
|
|
52
|
+
if path.endswith("/spans"):
|
|
53
|
+
spans = payload.get("spans", [])
|
|
54
|
+
elif path == "/api/v1/traces":
|
|
55
|
+
spans = payload.get("spans", [])
|
|
56
|
+
elif path == "/api/v1/traces/batch":
|
|
57
|
+
for trace in payload:
|
|
58
|
+
spans.extend(trace.get("spans", []))
|
|
59
|
+
for span in spans:
|
|
60
|
+
seen.setdefault(str(span["span_id"]), []).append(path)
|
|
61
|
+
return seen
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _exporter(wire: _Wire) -> BatchExporter:
|
|
65
|
+
config = Config(api_key="test", endpoint="http://localhost:9999")
|
|
66
|
+
exporter = BatchExporter(config)
|
|
67
|
+
exporter._client = httpx.Client(
|
|
68
|
+
base_url=config.endpoint,
|
|
69
|
+
headers={"X-API-Key": config.api_key, "Content-Type": "application/json"},
|
|
70
|
+
timeout=5.0,
|
|
71
|
+
transport=httpx.MockTransport(wire.handler),
|
|
72
|
+
)
|
|
73
|
+
return exporter
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ManualStepProgram(dspy.Module):
|
|
77
|
+
"""A program that opens a span of its own — the shape that puts a ``span``
|
|
78
|
+
item and its trace's ``trace`` item in the same flush."""
|
|
79
|
+
|
|
80
|
+
def __init__(self) -> None:
|
|
81
|
+
super().__init__()
|
|
82
|
+
self.predict = dspy.Predict(CapitalQA)
|
|
83
|
+
|
|
84
|
+
def forward(self, question: str) -> Any:
|
|
85
|
+
with cmpnd.start_span("manual_step") as span:
|
|
86
|
+
span.set_attribute("step", "lookup")
|
|
87
|
+
return self.predict(question=question)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _run_under_exporter(program: dspy.Module) -> _Wire:
|
|
91
|
+
"""Run ``program`` with the callback exporting into a real BatchExporter,
|
|
92
|
+
then flush that exporter's whole queue in one batch."""
|
|
93
|
+
from cmpnd.callback import CmpndCallback
|
|
94
|
+
|
|
95
|
+
wire = _Wire()
|
|
96
|
+
exporter = _exporter(wire)
|
|
97
|
+
|
|
98
|
+
with mock.patch("cmpnd.exporter.ensure_exporter_started"):
|
|
99
|
+
callback = CmpndCallback()
|
|
100
|
+
|
|
101
|
+
saved = list(dspy.settings.callbacks or [])
|
|
102
|
+
try:
|
|
103
|
+
dspy.settings.configure(callbacks=[callback])
|
|
104
|
+
with (
|
|
105
|
+
mock.patch("cmpnd.exporter.get_exporter", return_value=exporter),
|
|
106
|
+
dspy.context(lm=dspy.LM(fake_lm.FAKE_MODEL, cache=False)),
|
|
107
|
+
):
|
|
108
|
+
program(question="What is the capital of France?")
|
|
109
|
+
finally:
|
|
110
|
+
dspy.settings.configure(callbacks=saved)
|
|
111
|
+
|
|
112
|
+
batch = []
|
|
113
|
+
for q in (exporter._lifecycle_queue, exporter._detail_queue):
|
|
114
|
+
while not q.empty():
|
|
115
|
+
batch.append(q.get_nowait())
|
|
116
|
+
exporter._flush_batch(batch)
|
|
117
|
+
return wire
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_a_program_opened_span_is_not_sent_on_both_routes():
|
|
121
|
+
wire = _run_under_exporter(ManualStepProgram())
|
|
122
|
+
carried = wire.span_ids_by_route()
|
|
123
|
+
|
|
124
|
+
manual = [routes for routes in carried.values() if len(routes) > 1]
|
|
125
|
+
assert manual == [], f"span sent more than once: {manual}"
|
|
126
|
+
assert carried, "the run produced no spans at all"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_every_span_of_a_batched_trace_rides_its_body():
|
|
130
|
+
"""A non-streaming trace and its spans in one flush means one POST carries
|
|
131
|
+
all of it, and the per-trace spans route is never called."""
|
|
132
|
+
wire = _run_under_exporter(ManualStepProgram())
|
|
133
|
+
|
|
134
|
+
spans_routes = [r for r in wire.requests if r.url.path.endswith("/spans")]
|
|
135
|
+
assert spans_routes == [], f"spans route called for a batched trace: {[r.url.path for r in spans_routes]}"
|
|
136
|
+
|
|
137
|
+
names = {
|
|
138
|
+
span["name"]
|
|
139
|
+
for request in wire.requests
|
|
140
|
+
if request.url.path == "/api/v1/traces"
|
|
141
|
+
for span in decode_body(request).get("spans", [])
|
|
142
|
+
}
|
|
143
|
+
assert "manual_step" in names
|
|
144
|
+
assert "Predict.forward" in names
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_callback_does_not_stream_spans_of_a_non_streaming_trace():
|
|
148
|
+
"""The classification half of the rule: a span the callback tracked for a
|
|
149
|
+
batched trace is never handed to ``export_span``, whichever thread it ends on."""
|
|
150
|
+
from cmpnd.callback import CmpndCallback
|
|
151
|
+
|
|
152
|
+
with mock.patch("cmpnd.exporter.ensure_exporter_started"):
|
|
153
|
+
callback = CmpndCallback()
|
|
154
|
+
recorder = RecordingExporter()
|
|
155
|
+
|
|
156
|
+
saved = list(dspy.settings.callbacks or [])
|
|
157
|
+
try:
|
|
158
|
+
dspy.settings.configure(callbacks=[callback])
|
|
159
|
+
with (
|
|
160
|
+
mock.patch("cmpnd.exporter.get_exporter", return_value=recorder),
|
|
161
|
+
dspy.context(lm=dspy.LM(fake_lm.FAKE_MODEL, cache=False)),
|
|
162
|
+
):
|
|
163
|
+
evaluate = dspy.Evaluate(
|
|
164
|
+
devset=[
|
|
165
|
+
dspy.Example(question="What is the capital of France?", answer="Paris").with_inputs("question")
|
|
166
|
+
],
|
|
167
|
+
metric=lambda example, prediction, trace=None: 1.0,
|
|
168
|
+
num_threads=4,
|
|
169
|
+
display_progress=False,
|
|
170
|
+
display_table=False,
|
|
171
|
+
)
|
|
172
|
+
evaluate(dspy.Predict(CapitalQA))
|
|
173
|
+
finally:
|
|
174
|
+
dspy.settings.configure(callbacks=saved)
|
|
175
|
+
|
|
176
|
+
streamed = {str(span.span_id) for span in recorder.spans}
|
|
177
|
+
batched = {str(span.span_id) for trace in recorder.traces for span in (trace._batch_spans or [])}
|
|
178
|
+
assert streamed & batched == set()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|