From cd4f17de4814d82f6dbdbe11cbf24a8fc8c73fa4 Mon Sep 17 00:00:00 2001 From: James Gibbons Date: Sat, 18 Apr 2026 11:26:01 -0700 Subject: [PATCH 1/7] docs: design spec for UpDownCounter buffered metrics port Port plan mirroring sdk-typescript PR #2007 onto sdk-python. Scope limited to Buffered Metrics pipeline and Runtime MetricMeter; workflow/activity/ nexus context meters intentionally untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...8-updowncounter-buffered-metrics-design.md | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-18-updowncounter-buffered-metrics-design.md diff --git a/docs/superpowers/specs/2026-04-18-updowncounter-buffered-metrics-design.md b/docs/superpowers/specs/2026-04-18-updowncounter-buffered-metrics-design.md new file mode 100644 index 000000000..0e9a6f060 --- /dev/null +++ b/docs/superpowers/specs/2026-04-18-updowncounter-buffered-metrics-design.md @@ -0,0 +1,122 @@ +# Port: UpDownCounter in Buffered Metrics and Runtime MetricMeter + +**Date:** 2026-04-18 +**Branch:** `add_updowncounter_to_buffered_metrics` +**Upstream reference:** temporalio/sdk-typescript PR #2007 (commits `69118e3f`, `85b088fd`, `e35fe782`) + +## Goal + +Port the TypeScript SDK's UpDownCounter feature to the Python SDK. An UpDownCounter +is a metric instrument that accepts signed (positive or negative) delta values, +exposed through the Buffered Metrics pipeline and through `Runtime.metric_meter`. +This change intentionally does **not** plumb UpDownCounter into workflow, +activity, or nexus context meters — matching the upstream PR's scope. + +## Background + +`sdk-core` at the commit already pinned by sdk-python (`b544f95d`) exposes +`TemporalMeter::up_down_counter(...)`, the `UpDownCounter` core type, and the +`MetricUpdateVal::SignedDelta` / `MetricKind::UpDownCounter` variants. The +Python bridge's `convert_metric_event` already branches on both (kind=3, +`BufferedMetricUpdateValue::I64(v)`), but there is no Python-side API to +*create* an UpDownCounter or emit values through it. + +Existing public surface that UpDownCounter mirrors: + +- `temporalio.common.MetricCounter` / `temporalio.runtime._MetricCounter` +- `temporalio.bridge.metric.MetricCounter` / `temporalio.bridge.src.metric.MetricCounterRef` + +## Non-goals + +- Workflow, activity, and nexus context meters are out of scope (matches TS). + These meters will raise `NotImplementedError` if `create_up_down_counter` is + called on them. +- No changes to the sdk-core submodule pin. +- No OpenTelemetry / Prometheus config changes — the runtime already routes + UpDownCounter updates through the buffered pipeline and the core meter. + +## Design + +### 1. Rust bridge (`temporalio/bridge/src/metric.rs`) + +Add a new pyclass `MetricUpDownCounterRef` holding a `metrics::UpDownCounter`. +Add `MetricMeterRef::new_up_down_counter(name, description, unit)` delegating +to `self.meter.up_down_counter(build_metric_parameters(...))`. Add a +`MetricUpDownCounterRef::add(value: i64, attrs_ref)` method that calls +`self.counter.add(value, &attrs_ref.attrs)` — note the value is `i64`, not +`u64`, since the whole point is signed deltas. + +No changes needed to `convert_metric_event` or `BufferedMetric::kind` — both +already handle `UpDownCounter` (kind=3) and `SignedDelta`. + +### 2. Python bridge (`temporalio/bridge/metric.py`) + +Add a `MetricUpDownCounter` wrapper class paralleling `MetricCounter`, but with +**no non-negative validation** in `add` (it must accept negative values): + +```python +class MetricUpDownCounter: + def __init__(self, meter, name, description, unit) -> None: + self._ref = meter._ref.new_up_down_counter(name, description, unit) + + def add(self, value: int, attrs: MetricAttributes) -> None: + self._ref.add(value, attrs._ref) +``` + +### 3. Public ABC (`temporalio/common.py`) + +- Add `MetricUpDownCounter(MetricCommon)` ABC with an abstract `add(value: int, + additional_attributes)` method. Unlike `MetricCounter`, its docstring does + *not* require a non-negative value. +- Add `create_up_down_counter(name, description, unit) -> MetricUpDownCounter` + to the `MetricMeter` ABC as a **concrete, non-abstract** method that raises + `NotImplementedError` by default. This mirrors TS's optional `createUpDownCounter?` + field and avoids breaking user subclasses of `MetricMeter`. A leading FIXME + comment notes the default should be removed once support is complete on all + known implementations. +- Add `_NoopMetricUpDownCounter` and override `create_up_down_counter` on + `_NoopMetricMeter` to return it. + +### 4. Runtime (`temporalio/runtime.py`) + +- Add public constant `BUFFERED_METRIC_KIND_UP_DOWN_COUNTER = BufferedMetricKind(3)`. +- Update `BufferedMetric.kind` docstring to mention the new constant. +- Add `_MetricUpDownCounter(temporalio.common.MetricUpDownCounter, + _MetricCommon[temporalio.bridge.metric.MetricUpDownCounter])` with `add()` + that does **not** raise on negative values. +- Implement `_MetricMeter.create_up_down_counter(...)` returning + `_MetricUpDownCounter(...)`. + +### 5. Intentionally untouched + +- `_ReplaySafeMetricMeter` in `temporalio/worker/_workflow_instance.py` — will + inherit the ABC's default `NotImplementedError`. +- Nexus / activity context meters — same. + +### 6. Testing + +Extend `tests/worker/test_workflow.py::test_runtime_buffered_metrics`, or add +a new focused test in the same file, that: + +- Creates an UpDownCounter via `runtime.metric_meter.create_up_down_counter(...)`. +- Emits a positive value, another positive value, and a negative value. +- Drains the buffer and asserts `BUFFERED_METRIC_KIND_UP_DOWN_COUNTER`, the + metric name/description/unit, and the three values (including the negative). + +## Open questions — confirmed with user + +- `create_up_down_counter` is non-abstract with a `NotImplementedError` default + on the ABC (non-breaking). **Confirmed.** +- Match TS scope: workflow / activity / nexus meters are untouched. + **Confirmed.** +- Test location decided at implementation time — extend existing + `test_runtime_buffered_metrics` or add a sibling test in the same file. + +## Risks + +- The ABC addition is non-breaking only if users call `create_up_down_counter` + explicitly on meters that support it. Callers that treat every meter as + having the method will hit `NotImplementedError` at runtime. Acceptable — + matches TS semantics of "optional". +- The bridge adds a new NAPI-style pyo3 class; must be re-compiled (`maturin + develop` or equivalent) before Python tests pass. From f508b15c726813ed3bab13c4c9a1a0af0760f6be Mon Sep 17 00:00:00 2001 From: James Gibbons Date: Sat, 18 Apr 2026 11:29:40 -0700 Subject: [PATCH 2/7] docs: implementation plan for UpDownCounter buffered metrics port Step-by-step plan driving the TDD/commit cadence for porting sdk-typescript PR #2007. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...26-04-18-updowncounter-buffered-metrics.md | 536 ++++++++++++++++++ 1 file changed, 536 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-18-updowncounter-buffered-metrics.md diff --git a/docs/superpowers/plans/2026-04-18-updowncounter-buffered-metrics.md b/docs/superpowers/plans/2026-04-18-updowncounter-buffered-metrics.md new file mode 100644 index 000000000..6b3f97f68 --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-updowncounter-buffered-metrics.md @@ -0,0 +1,536 @@ +# UpDownCounter in Buffered Metrics — Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add UpDownCounter (signed-delta counter) support to the Python SDK's Buffered Metrics pipeline and `Runtime.metric_meter`, matching sdk-typescript PR #2007. + +**Architecture:** Thin pyo3 bridge wrapping `temporalio_common::metrics::UpDownCounter`, surfaced as a new `MetricUpDownCounter` ABC in `temporalio.common`, with a concrete implementation on the runtime meter. A new buffered-metric kind constant (`BUFFERED_METRIC_KIND_UP_DOWN_COUNTER = 3`) parallels the existing counter/gauge/histogram kinds. Workflow, activity, and nexus context meters are intentionally untouched — the ABC's `create_up_down_counter` default raises `NotImplementedError` to mirror the TS "optional" method. + +**Tech Stack:** Rust + pyo3 + maturin for the bridge, Python 3 for the wrappers/ABC, `pytest` + `uv` + `poethepoet` for the build/test loop. + +**Reference commits (sdk-typescript):** `69118e3f`, `85b088fd`, `e35fe782`. + +**Spec:** `docs/superpowers/specs/2026-04-18-updowncounter-buffered-metrics-design.md`. + +--- + +## Chunk 1: Failing test + Rust bridge + +### Task 1: Write a failing buffered-metrics test for UpDownCounter + +**Files:** +- Modify: `tests/worker/test_workflow.py` (extend imports near line ~100 and add a new test near existing `test_runtime_buffered_metrics`) + +This test drives out the whole surface: `runtime.metric_meter.create_up_down_counter`, `add()` accepting negative values, `with_additional_attributes`, and the buffered update kind. + +- [ ] **Step 1: Add the new buffered-metric-kind constant to the test imports** + +Find the existing import block near line 104: + +```python +from temporalio.runtime import ( + ... + BUFFERED_METRIC_KIND_COUNTER, + BUFFERED_METRIC_KIND_HISTOGRAM, + ... +) +``` + +Add `BUFFERED_METRIC_KIND_UP_DOWN_COUNTER` alongside the other kind constants in that import. + +- [ ] **Step 2: Add a new test function** + +Place this test immediately after `test_runtime_buffered_metrics` in `tests/worker/test_workflow.py`. It is standalone — does not need a Temporal server, just a runtime with a metric buffer. + +```python +async def test_runtime_buffered_metrics_up_down_counter() -> None: + # Create runtime with metric buffer + buffer = MetricBuffer(10000) + runtime = Runtime(telemetry=TelemetryConfig(metrics=buffer)) + + # Confirm no updates yet + assert not buffer.retrieve_updates() + + # Create an up-down counter and a sibling with extra attrs + up_down = runtime.metric_meter.create_up_down_counter( + "runtime-up-down-counter", + "runtime-up-down-counter-desc", + "runtime-up-down-counter-unit", + ) + up_down_with_attrs = up_down.with_additional_attributes({"foo": "bar"}) + + # Emit a positive delta, a larger positive delta, and a negative delta + up_down.add(1) + up_down.add(5) + up_down_with_attrs.add(-3) + + updates = buffer.retrieve_updates() + assert len(updates) == 3 + + # Metric metadata + assert updates[0].metric.name == "runtime-up-down-counter" + assert updates[0].metric.description == "runtime-up-down-counter-desc" + assert updates[0].metric.unit == "runtime-up-down-counter-unit" + assert updates[0].metric.kind == BUFFERED_METRIC_KIND_UP_DOWN_COUNTER + # Exact-same metric object across updates (performance invariant) + assert id(updates[0].metric) == id(updates[1].metric) + assert id(updates[0].metric) == id(updates[2].metric) + + # Values include the negative delta + assert updates[0].value == 1 + assert updates[1].value == 5 + assert updates[2].value == -3 + + # Attributes match + assert updates[0].attributes == {"service_name": "temporal-core-sdk"} + assert updates[2].attributes == { + "service_name": "temporal-core-sdk", + "foo": "bar", + } +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: +```bash +uv run pytest tests/worker/test_workflow.py::test_runtime_buffered_metrics_up_down_counter -v +``` +Expected: `ImportError` on `BUFFERED_METRIC_KIND_UP_DOWN_COUNTER`, or `AttributeError: ... has no attribute 'create_up_down_counter'` once that import is resolved. + +- [ ] **Step 4: Commit the failing test** + +```bash +git add tests/worker/test_workflow.py +git commit -m "test: add failing buffered-metrics UpDownCounter test" +``` + +--- + +### Task 2: Add `UpDownCounter` to the Rust bridge + +**Files:** +- Modify: `temporalio/bridge/src/metric.rs` + +`convert_metric_event` already handles `MetricKind::UpDownCounter` (kind=3) and `MetricUpdateVal::SignedDelta` — no changes there. We only need a pyclass + a meter method + an `add(i64, ...)` method. + +- [ ] **Step 1: Add the `MetricUpDownCounterRef` pyclass** + +In `temporalio/bridge/src/metric.rs`, alongside the other ref structs (after `MetricGaugeFloatRef` at ~line 56): + +```rust +#[pyclass] +pub struct MetricUpDownCounterRef { + counter: metrics::UpDownCounter, +} +``` + +- [ ] **Step 2: Add `new_up_down_counter` to `MetricMeterRef`** + +Inside `#[pymethods] impl MetricMeterRef { ... }` (after `new_gauge_float` at ~line 155), add: + +```rust + fn new_up_down_counter( + &self, + name: String, + description: Option, + unit: Option, + ) -> MetricUpDownCounterRef { + MetricUpDownCounterRef { + counter: self + .meter + .up_down_counter(build_metric_parameters(name, description, unit)), + } + } +``` + +- [ ] **Step 3: Add the `add` impl for `MetricUpDownCounterRef`** + +After the `MetricGaugeFloatRef` `#[pymethods]` block (around line 199), add: + +```rust +#[pymethods] +impl MetricUpDownCounterRef { + fn add(&self, value: i64, attrs_ref: &MetricAttributesRef) { + self.counter.add(value, &attrs_ref.attrs); + } +} +``` + +Note: value is `i64` (signed), **not** `u64`. + +- [ ] **Step 4: Run `cargo fmt` and `bridge-lint`** + +```bash +uv run poe bridge-lint +(cd temporalio/bridge && cargo fmt) +``` +Expected: no warnings, clean format. + +- [ ] **Step 5: Build the bridge** + +```bash +uv run poe build-develop +``` +Expected: successful compile; `temporal_sdk_bridge` rebuilt. + +- [ ] **Step 6: Commit the Rust bridge changes** + +```bash +git add temporalio/bridge/src/metric.rs +git commit -m "bridge: expose UpDownCounter via MetricMeterRef" +``` + +--- + +## Chunk 2: Python layers + +### Task 3: Add the Python bridge wrapper + +**Files:** +- Modify: `temporalio/bridge/metric.py` + +- [ ] **Step 1: Add `MetricUpDownCounter` wrapper class** + +After the `MetricCounter` class (after line 56), insert: + +```python +class MetricUpDownCounter: + """Metric up-down counter using SDK Core.""" + + def __init__( + self, + meter: MetricMeter, + name: str, + description: str | None, + unit: str | None, + ) -> None: + """Initialize up-down counter metric.""" + self._ref = meter._ref.new_up_down_counter(name, description, unit) + + def add(self, value: int, attrs: MetricAttributes) -> None: + """Add value to up-down counter. + + Value may be negative. + """ + self._ref.add(value, attrs._ref) +``` + +Note: no `if value < 0: raise ValueError(...)` — the whole point of an up-down counter is to accept negatives. + +- [ ] **Step 2: Verify the module imports cleanly** + +```bash +uv run python -c "import temporalio.bridge.metric; print(temporalio.bridge.metric.MetricUpDownCounter)" +``` +Expected: prints the class, no errors. + +- [ ] **Step 3: Commit** + +```bash +git add temporalio/bridge/metric.py +git commit -m "bridge: add Python MetricUpDownCounter wrapper" +``` + +--- + +### Task 4: Extend the public ABC in `temporalio.common` + +**Files:** +- Modify: `temporalio/common.py` + +- [ ] **Step 1: Add the `MetricUpDownCounter` ABC** + +In `temporalio/common.py`, after `MetricGaugeFloat` (around line 895), add: + +```python +class MetricUpDownCounter(MetricCommon): + """Up-down counter metric created by a metric meter.""" + + @abstractmethod + def add( + self, value: int, additional_attributes: MetricAttributes | None = None + ) -> None: + """Add a value to the up-down counter. + + Value may be negative. + + Args: + value: An integer to add (can be positive or negative). + additional_attributes: Additional attributes to append to the + current set. + + Raises: + TypeError: Attribute values are not the expected type. + """ + ... +``` + +- [ ] **Step 2: Add `create_up_down_counter` to the `MetricMeter` ABC** + +Inside `class MetricMeter(ABC):` (near line 707, right before `with_additional_attributes`), add a **non-abstract, default-raising** method. This mirrors the TS optional `createUpDownCounter?`. + +```python + # FIXME: Make this abstract once up-down-counter support is complete + # on every MetricMeter implementation. + def create_up_down_counter( + self, name: str, description: str | None = None, unit: str | None = None + ) -> MetricUpDownCounter: + """Create an up-down counter metric that accepts signed values. + + Args: + name: Name for the metric. + description: Optional description for the metric. + unit: Optional unit for the metric. + + Returns: + Up-down counter metric. + + Raises: + NotImplementedError: If this meter does not support up-down + counters (e.g. workflow or activity context meters). + """ + raise NotImplementedError( + "create_up_down_counter is not supported by this MetricMeter" + ) +``` + +- [ ] **Step 3: Add the noop `_NoopMetricUpDownCounter` + override on `_NoopMetricMeter`** + +After `_NoopMetricGaugeFloat` (around line 998), add: + +```python +class _NoopMetricUpDownCounter(MetricUpDownCounter, _NoopMetric): + def add( + self, value: int, additional_attributes: MetricAttributes | None = None + ) -> None: + pass +``` + +Inside `class _NoopMetricMeter(MetricMeter):` (around line 927, alongside the other `create_*` methods), add an override: + +```python + def create_up_down_counter( + self, name: str, description: str | None = None, unit: str | None = None + ) -> MetricUpDownCounter: + return _NoopMetricUpDownCounter(name, description, unit) +``` + +- [ ] **Step 4: Syntax check** + +```bash +uv run python -c "from temporalio.common import MetricMeter, MetricUpDownCounter; m = MetricMeter.noop; print(m.create_up_down_counter('x'))" +``` +Expected: prints a `_NoopMetricUpDownCounter` instance without error. + +- [ ] **Step 5: Confirm the default raises on a stub subclass** + +```bash +uv run python -c " +from temporalio.common import MetricMeter +class Stub(MetricMeter): + def create_counter(self, *a, **k): ... + def create_histogram(self, *a, **k): ... + def create_histogram_float(self, *a, **k): ... + def create_histogram_timedelta(self, *a, **k): ... + def create_gauge(self, *a, **k): ... + def create_gauge_float(self, *a, **k): ... + def with_additional_attributes(self, *a, **k): ... +try: + Stub().create_up_down_counter('x') +except NotImplementedError as e: + print('OK:', e) +" +``` +Expected: prints `OK: create_up_down_counter is not supported by this MetricMeter`. + +- [ ] **Step 6: Commit** + +```bash +git add temporalio/common.py +git commit -m "common: add MetricUpDownCounter ABC and noop impl" +``` + +--- + +### Task 5: Wire UpDownCounter through the runtime meter + +**Files:** +- Modify: `temporalio/runtime.py` + +- [ ] **Step 1: Add the buffered-metric-kind constant and update docstring** + +After `BUFFERED_METRIC_KIND_HISTOGRAM` (around line 518): + +```python +BUFFERED_METRIC_KIND_UP_DOWN_COUNTER = BufferedMetricKind(3) +"""Buffered metric is an up-down counter which means values are signed deltas.""" +``` + +Update the `BufferedMetric.kind` docstring (around line 547-553) to include the new kind: + +```python + @property + def kind(self) -> BufferedMetricKind: + """Get the metric kind. + + This is one of :py:const:`BUFFERED_METRIC_KIND_COUNTER`, + :py:const:`BUFFERED_METRIC_KIND_GAUGE`, + :py:const:`BUFFERED_METRIC_KIND_HISTOGRAM`, or + :py:const:`BUFFERED_METRIC_KIND_UP_DOWN_COUNTER`. + """ + ... +``` + +- [ ] **Step 2: Add `_MetricUpDownCounter`** + +After `_MetricGaugeFloat` (around line 836), add: + +```python +class _MetricUpDownCounter( + temporalio.common.MetricUpDownCounter, + _MetricCommon[temporalio.bridge.metric.MetricUpDownCounter], +): + def add( + self, + value: int, + additional_attributes: temporalio.common.MetricAttributes | None = None, + ) -> None: + core_attrs = self._core_attrs + if additional_attributes: + core_attrs = core_attrs.with_additional_attributes(additional_attributes) + self._core_metric.add(value, core_attrs) +``` + +Note: intentionally **no** `if value < 0: raise ValueError(...)`. + +- [ ] **Step 3: Implement `create_up_down_counter` on `_MetricMeter`** + +Inside `class _MetricMeter(temporalio.common.MetricMeter):` (after `create_gauge_float` around line 680, before `with_additional_attributes`): + +```python + def create_up_down_counter( + self, name: str, description: str | None = None, unit: str | None = None + ) -> temporalio.common.MetricUpDownCounter: + return _MetricUpDownCounter( + name, + description, + unit, + temporalio.bridge.metric.MetricUpDownCounter( + self._core_meter, name, description, unit + ), + self._core_attrs, + ) +``` + +- [ ] **Step 4: Syntax + light import check** + +```bash +uv run python -c " +from temporalio.runtime import ( + BUFFERED_METRIC_KIND_UP_DOWN_COUNTER, + Runtime, TelemetryConfig, MetricBuffer, +) +print(BUFFERED_METRIC_KIND_UP_DOWN_COUNTER) +" +``` +Expected: prints `3`. + +- [ ] **Step 5: Commit** + +```bash +git add temporalio/runtime.py +git commit -m "runtime: expose UpDownCounter via Runtime.metric_meter" +``` + +--- + +## Chunk 3: Verification + polish + +### Task 6: Run the test end-to-end + +- [ ] **Step 1: Rebuild the bridge (picks up Rust changes)** + +```bash +uv run poe build-develop +``` +Expected: successful compile. + +- [ ] **Step 2: Run the new test** + +```bash +uv run pytest tests/worker/test_workflow.py::test_runtime_buffered_metrics_up_down_counter -v +``` +Expected: PASS. + +- [ ] **Step 3: Run the existing buffered-metrics test to confirm no regression** + +```bash +uv run pytest tests/worker/test_workflow.py::test_runtime_buffered_metrics -v +``` +Expected: PASS (unchanged behavior). + +- [ ] **Step 4: If either test fails, debug via superpowers:systematic-debugging before moving on** + +--- + +### Task 7: Lint and format + +- [ ] **Step 1: Ruff format/lint the Python** + +```bash +uv run poe format +uv run poe lint +``` +Expected: no errors. + +- [ ] **Step 2: Clippy the bridge** + +```bash +uv run poe bridge-lint +``` +Expected: no warnings. + +- [ ] **Step 3: If lint introduced any changes, commit them** + +```bash +git add -u +git diff --cached --quiet || git commit -m "chore: lint and format" +``` + +--- + +### Task 8: Final review before handoff + +- [ ] **Step 1: Diff the full branch against main** + +```bash +git diff --stat main...HEAD +git log --oneline main..HEAD +``` + +Expected files changed (non-exhaustive): +- `docs/superpowers/specs/2026-04-18-updowncounter-buffered-metrics-design.md` +- `docs/superpowers/plans/2026-04-18-updowncounter-buffered-metrics.md` +- `temporalio/bridge/src/metric.rs` +- `temporalio/bridge/metric.py` +- `temporalio/common.py` +- `temporalio/runtime.py` +- `tests/worker/test_workflow.py` + +- [ ] **Step 2: Use superpowers:verification-before-completion before claiming done** + +Specifically: re-run the new test in isolation, then confirm `uv run poe lint` and `uv run poe bridge-lint` are clean. + +- [ ] **Step 3: Report status to user** with: + - Test output for the new test (pass) + - Summary of files changed + - Any deviations from the plan + +--- + +## Notes / pitfalls + +- The Rust `add(value: i64, ...)` type is load-bearing — if this is left as `u64`, negative values will either wrap or fail silently depending on Python int marshalling. +- `convert_metric_event` already maps `SignedDelta` to `BufferedMetricUpdateValue::I64(v)` (which becomes a Python `int`), so no conversion work is needed in the Python test assertions — values come out as `int`, not `float`. +- The TS PR intentionally does not plumb UpDownCounter through workflow/activity context meters. Do not add `create_up_down_counter` to `_ReplaySafeMetricMeter` or to activity/nexus context meters. The ABC's default `NotImplementedError` is the correct behavior for those meters. +- `BUFFERED_METRIC_KIND_UP_DOWN_COUNTER = 3` must match the integer already emitted by `temporalio/bridge/src/metric.rs::convert_metric_event` (line 352). Verify before claiming done. From 3b23ea7bfb9c33107d132fc7ec6e9d568f391f95 Mon Sep 17 00:00:00 2001 From: James Gibbons Date: Sat, 18 Apr 2026 11:38:36 -0700 Subject: [PATCH 3/7] test: add failing buffered-metrics UpDownCounter test Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/worker/test_workflow.py | 47 +++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/worker/test_workflow.py b/tests/worker/test_workflow.py index b239841b5..64990f39b 100644 --- a/tests/worker/test_workflow.py +++ b/tests/worker/test_workflow.py @@ -103,6 +103,7 @@ from temporalio.runtime import ( BUFFERED_METRIC_KIND_COUNTER, BUFFERED_METRIC_KIND_HISTOGRAM, + BUFFERED_METRIC_KIND_UP_DOWN_COUNTER, MetricBuffer, MetricBufferDurationFormat, PrometheusConfig, @@ -4260,6 +4261,52 @@ async def test_workflow_buffered_metrics(client: Client): ) +async def test_runtime_buffered_metrics_up_down_counter() -> None: + # Create runtime with metric buffer + buffer = MetricBuffer(10000) + runtime = Runtime(telemetry=TelemetryConfig(metrics=buffer)) + + # Confirm no updates yet + assert not buffer.retrieve_updates() + + # Create an up-down counter and a sibling with extra attrs + up_down = runtime.metric_meter.create_up_down_counter( + "runtime-up-down-counter", + "runtime-up-down-counter-desc", + "runtime-up-down-counter-unit", + ) + up_down_with_attrs = up_down.with_additional_attributes({"foo": "bar"}) + + # Emit a positive delta, a larger positive delta, and a negative delta + up_down.add(1) + up_down.add(5) + up_down_with_attrs.add(-3) + + updates = buffer.retrieve_updates() + assert len(updates) == 3 + + # Metric metadata + assert updates[0].metric.name == "runtime-up-down-counter" + assert updates[0].metric.description == "runtime-up-down-counter-desc" + assert updates[0].metric.unit == "runtime-up-down-counter-unit" + assert updates[0].metric.kind == BUFFERED_METRIC_KIND_UP_DOWN_COUNTER + # Exact-same metric object across updates (performance invariant) + assert id(updates[0].metric) == id(updates[1].metric) + assert id(updates[0].metric) == id(updates[2].metric) + + # Values include the negative delta + assert updates[0].value == 1 + assert updates[1].value == 5 + assert updates[2].value == -3 + + # Attributes match + assert updates[0].attributes == {"service_name": "temporal-core-sdk"} + assert updates[2].attributes == { + "service_name": "temporal-core-sdk", + "foo": "bar", + } + + async def test_workflow_metrics_other_types(client: Client): async def do_stuff(buffer: MetricBuffer) -> None: runtime = Runtime(telemetry=TelemetryConfig(metrics=buffer)) From 0ae9e5f7e90231ed3285c696c243607178334517 Mon Sep 17 00:00:00 2001 From: James Gibbons Date: Sat, 18 Apr 2026 11:51:49 -0700 Subject: [PATCH 4/7] bridge: expose UpDownCounter via MetricMeterRef Co-Authored-By: Claude Opus 4.7 (1M context) --- temporalio/bridge/src/metric.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/temporalio/bridge/src/metric.rs b/temporalio/bridge/src/metric.rs index 445adfea9..ed27a392b 100644 --- a/temporalio/bridge/src/metric.rs +++ b/temporalio/bridge/src/metric.rs @@ -55,6 +55,11 @@ pub struct MetricGaugeFloatRef { gauge: metrics::GaugeF64, } +#[pyclass] +pub struct MetricUpDownCounterRef { + counter: metrics::UpDownCounter, +} + pub fn new_metric_meter(runtime_ref: &runtime::RuntimeRef) -> Option { runtime_ref .runtime @@ -153,6 +158,19 @@ impl MetricMeterRef { .gauge_f64(build_metric_parameters(name, description, unit)), } } + + fn new_up_down_counter( + &self, + name: String, + description: Option, + unit: Option, + ) -> MetricUpDownCounterRef { + MetricUpDownCounterRef { + counter: self + .meter + .up_down_counter(build_metric_parameters(name, description, unit)), + } + } } #[pymethods] @@ -198,6 +216,13 @@ impl MetricGaugeFloatRef { } } +#[pymethods] +impl MetricUpDownCounterRef { + fn add(&self, value: i64, attrs_ref: &MetricAttributesRef) { + self.counter.add(value, &attrs_ref.attrs); + } +} + fn build_metric_parameters( name: String, description: Option, From d8ba1508fa4397093a66eb4aa1613e05c4cd990f Mon Sep 17 00:00:00 2001 From: James Gibbons Date: Sat, 18 Apr 2026 11:53:07 -0700 Subject: [PATCH 5/7] bridge: add Python MetricUpDownCounter wrapper Co-Authored-By: Claude Opus 4.7 (1M context) --- temporalio/bridge/metric.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/temporalio/bridge/metric.py b/temporalio/bridge/metric.py index 4b8d7453d..616bf1216 100644 --- a/temporalio/bridge/metric.py +++ b/temporalio/bridge/metric.py @@ -55,6 +55,27 @@ def add(self, value: int, attrs: MetricAttributes) -> None: self._ref.add(value, attrs._ref) +class MetricUpDownCounter: + """Metric up-down counter using SDK Core.""" + + def __init__( + self, + meter: MetricMeter, + name: str, + description: str | None, + unit: str | None, + ) -> None: + """Initialize up-down counter metric.""" + self._ref = meter._ref.new_up_down_counter(name, description, unit) + + def add(self, value: int, attrs: MetricAttributes) -> None: + """Add value to up-down counter. + + Value may be negative. + """ + self._ref.add(value, attrs._ref) + + class MetricHistogram: """Metric histogram using SDK Core.""" From 5831764f32e3c2a8cfe7287604c32da4cd6d6a64 Mon Sep 17 00:00:00 2001 From: James Gibbons Date: Sat, 18 Apr 2026 11:55:00 -0700 Subject: [PATCH 6/7] common: add MetricUpDownCounter ABC and noop impl Co-Authored-By: Claude Opus 4.7 (1M context) --- temporalio/common.py | 57 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/temporalio/common.py b/temporalio/common.py index 2f34c6387..73438fede 100644 --- a/temporalio/common.py +++ b/temporalio/common.py @@ -706,6 +706,29 @@ def create_gauge_float( """ ... + # FIXME: Make this abstract once up-down-counter support is complete + # on every MetricMeter implementation. + def create_up_down_counter( + self, name: str, description: str | None = None, unit: str | None = None + ) -> MetricUpDownCounter: + """Create an up-down counter metric that accepts signed values. + + Args: + name: Name for the metric. + description: Optional description for the metric. + unit: Optional unit for the metric. + + Returns: + Up-down counter metric. + + Raises: + NotImplementedError: If this meter does not support up-down + counters (e.g. workflow or activity context meters). + """ + raise NotImplementedError( + "create_up_down_counter is not supported by this MetricMeter" + ) + @abstractmethod def with_additional_attributes( self, additional_attributes: MetricAttributes @@ -895,6 +918,28 @@ def set( ... +class MetricUpDownCounter(MetricCommon): + """Up-down counter metric created by a metric meter.""" + + @abstractmethod + def add( + self, value: int, additional_attributes: MetricAttributes | None = None + ) -> None: + """Add a value to the up-down counter. + + Value may be negative. + + Args: + value: An integer to add (can be positive or negative). + additional_attributes: Additional attributes to append to the + current set. + + Raises: + TypeError: Attribute values are not the expected type. + """ + ... + + class _NoopMetricMeter(MetricMeter): def create_counter( self, name: str, description: str | None = None, unit: str | None = None @@ -926,6 +971,11 @@ def create_gauge_float( ) -> MetricGaugeFloat: return _NoopMetricGaugeFloat(name, description, unit) + def create_up_down_counter( + self, name: str, description: str | None = None, unit: str | None = None + ) -> MetricUpDownCounter: + return _NoopMetricUpDownCounter(name, description, unit) + def with_additional_attributes( self, additional_attributes: MetricAttributes ) -> MetricMeter: @@ -998,6 +1048,13 @@ def set( pass +class _NoopMetricUpDownCounter(MetricUpDownCounter, _NoopMetric): + def add( + self, value: int, additional_attributes: MetricAttributes | None = None + ) -> None: + pass + + MetricMeter.noop = _NoopMetricMeter() From d5fc337047a32903c5baf1abadc3c114225d052d Mon Sep 17 00:00:00 2001 From: James Gibbons Date: Sat, 18 Apr 2026 12:01:51 -0700 Subject: [PATCH 7/7] runtime: expose UpDownCounter via Runtime.metric_meter Co-Authored-By: Claude Opus 4.7 (1M context) --- temporalio/runtime.py | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/temporalio/runtime.py b/temporalio/runtime.py index 8fab68e9e..0be756897 100644 --- a/temporalio/runtime.py +++ b/temporalio/runtime.py @@ -518,6 +518,9 @@ def _to_bridge_config(self) -> temporalio.bridge.runtime.TelemetryConfig: BUFFERED_METRIC_KIND_HISTOGRAM = BufferedMetricKind(2) """Buffered metric is a histogram.""" +BUFFERED_METRIC_KIND_UP_DOWN_COUNTER = BufferedMetricKind(3) +"""Buffered metric is an up-down counter which means values are signed deltas.""" + # WARNING: This must match Rust metric::BufferedMetric class BufferedMetric(Protocol): @@ -548,8 +551,9 @@ def kind(self) -> BufferedMetricKind: """Get the metric kind. This is one of :py:const:`BUFFERED_METRIC_KIND_COUNTER`, - :py:const:`BUFFERED_METRIC_KIND_GAUGE`, or - :py:const:`BUFFERED_METRIC_KIND_HISTOGRAM`. + :py:const:`BUFFERED_METRIC_KIND_GAUGE`, + :py:const:`BUFFERED_METRIC_KIND_HISTOGRAM`, or + :py:const:`BUFFERED_METRIC_KIND_UP_DOWN_COUNTER`. """ ... @@ -679,6 +683,19 @@ def create_gauge_float( self._core_attrs, ) + def create_up_down_counter( + self, name: str, description: str | None = None, unit: str | None = None + ) -> temporalio.common.MetricUpDownCounter: + return _MetricUpDownCounter( + name, + description, + unit, + temporalio.bridge.metric.MetricUpDownCounter( + self._core_meter, name, description, unit + ), + self._core_attrs, + ) + def with_additional_attributes( self, additional_attributes: temporalio.common.MetricAttributes ) -> temporalio.common.MetricMeter: @@ -834,3 +851,18 @@ def set( if additional_attributes: core_attrs = core_attrs.with_additional_attributes(additional_attributes) self._core_metric.set(value, core_attrs) + + +class _MetricUpDownCounter( + temporalio.common.MetricUpDownCounter, + _MetricCommon[temporalio.bridge.metric.MetricUpDownCounter], +): + def add( + self, + value: int, + additional_attributes: temporalio.common.MetricAttributes | None = None, + ) -> None: + core_attrs = self._core_attrs + if additional_attributes: + core_attrs = core_attrs.with_additional_attributes(additional_attributes) + self._core_metric.add(value, core_attrs)