Problem statement
The Go Metrics SDK retains synchronous metric series after their last measurement. This is problematic for instrumentation that observes short-lived or dynamically changing entities: the SDK continues exporting their last aggregated value and retaining their aggregation state after those entities disappear.
OpenTelemetry specification issue #2232 tracks adding lifecycle support for these series, and specification PR #4702 proposes a Finish operation for synchronous instruments. The specification work remains in development, so this issue proposes an experimental Go API and SDK implementation that can evolve with it.
A practical requirement is allowing OBI to replace its forked Go metric API and SDK. OBI currently adds a Remove operation to synchronous instruments and uses it to expire series for entities that have not been observed within a configured TTL.
Previous prototypes include:
Goals
- Add an experimental, opt-in
Finish capability for an exact collection of attributes.
- Support all eight synchronous instrument types.
- Export the final aggregation once per reader before releasing its state.
- Allow callers that retain attribute key-values to finish a series without constructing an
attribute.Set.
- Avoid adding branches, allocations, or lifecycle state to providers that do not opt in.
- Provide an API that OBI can use instead of its forked
Remove functionality.
Experimental API
Add the following interface to go.opentelemetry.io/otel/metric/x:
package x
import (
"context"
"go.opentelemetry.io/otel/attribute"
)
// Finisher provides lifecycle control for an exact synchronous metric series.
type Finisher interface {
Finish(context.Context, ...attribute.KeyValue)
}
Callers detect the capability using a type assertion:
attrs := []attribute.KeyValue{
attribute.String("service.name", name),
attribute.Int("service.port", port),
}
counter.Add(ctx, 1, metric.WithAttributes(attrs...))
if finisher, ok := counter.(metricx.Finisher); ok {
finisher.Finish(ctx, attrs...)
}
The complete collection of attributes identifies one exact input series. It is not treated as a subset or predicate. Calling Finish(ctx) identifies only the series recorded without attributes; it does not mean “finish every series.”
Attribute order does not affect identity. Duplicate keys use the same last-value-wins semantics as recording.
Finish has no return value. Finishing an unknown or unsupported series is a no-op.
Implementations must not retain or modify the supplied attribute slice. This permits callers to reuse an existing slice safely and prevents an SDK implementation from sorting caller-owned storage in place.
Why ...attribute.KeyValue
Finish only needs to locate existing aggregation state. It does not need to store a new attribute set. Requiring an attribute.Set would require callers that naturally retain key-values to construct a persistent representation solely so the SDK can reduce it to a lookup key.
The variadic form:
- Matches the experimental Bind API shape.
- Supports direct, idiomatic calls with attribute key-values.
- Allows a caller that already retains a slice to call
Finish(ctx, attrs...) without a slice copy.
- Allows the SDK to canonicalize and hash the input without materializing an
attribute.Set.
- Keeps the initial experimental capability to one method.
The variadic syntax does not guarantee that every call site performs zero allocations. In particular, a call with inline key-values through a dynamically dispatched optional interface may cause the compiler-generated variadic backing array to escape. The zero-allocation caller path is reuse of an existing slice. Performance claims and benchmarks must distinguish these two call forms.
Why not attribute.Set, attribute.Distinct, or two methods
An attribute.Set remains useful when a caller needs a canonical immutable representation for repeated recording. It is not required for a Finish lookup, however, and constructing one can allocate.
attribute.Distinct is not a suitable public input. It is not reversible, so the SDK could not apply per-stream View filters to the original attributes. It is also a probabilistic, process-local identity rather than a stable external identifier.
This initial API will not additionally define FinishSet(context.Context, attribute.Set). Two public methods for the same operation would increase the implementation and documentation surface before usage demonstrates that both are necessary. If adoption shows that callers commonly possess only an attribute.Set, a separate optional set-based capability can be considered without changing the existing Finisher method set.
SDK activation
Add an experimental SDK option in go.opentelemetry.io/otel/sdk/metric/x:
provider := sdkmetric.NewMeterProvider(
sdkmetricx.WithFinish(),
)
Only instruments created directly from a provider configured with WithFinish() are required to implement metricx.Finisher.
Providers without WithFinish() retain their existing behavior and implementation paths.
Global provider limitation
Instruments obtained from the global MeterProvider proxy before the delegate provider is installed are not required to expose metricx.Finisher.
A Go value’s method set cannot gain an optional method after it has already been returned. Therefore, setting a finish-capable delegate later cannot make an existing proxy instrument satisfy the Finisher interface. This limitation must be documented in the package documentation and examples.
Lifecycle semantics
For each resolved metric stream and reader, a series is in one of three conceptual states:
active -> pending final -> forgotten
^ |
+----------+
measurement before collection
Finishing
Calling Finish for an active series marks that series as pending final.
Repeated calls while the series remains pending are idempotent. They do not change the first finish timestamp.
At the next collection for each reader:
- Any reportable aggregation state is emitted one final time.
- The final point’s
Time is the time of the Finish call, not the collection time.
- After that collection, the reader releases the series state.
- Subsequent collections omit the series.
For delta aggregation, Finish must not synthesize a zero or empty point if there have been no measurements since the preceding collection. The reader may release that series state without emitting another point.
“Collected” means returned by MetricReader.Collect. It does not mean successfully exported. Export retries and delivery guarantees remain the responsibility of the reader and exporter.
Reactivation before collection
If a measurement for the same post-view series arrives after Finish but before its final point is collected:
- The pending finish is cancelled.
- No final point is emitted.
- The measurement continues the existing series lifetime and aggregation.
If the final point has already been collected and forgotten, a later measurement creates a new series lifetime.
Start timestamps
Finish-aware aggregators must track per-series start timestamps independently of OTEL_GO_X_PER_SERIES_START_TIMESTAMPS.
The final cumulative point retains the original series start time. If the series is later recreated, its start time is the time of the first measurement in the new lifetime.
This is necessary to distinguish, for example, a recreated monotonic cumulative counter from an invalid decrease in the previous cumulative sequence. Enabling WithFinish() must be sufficient to provide this behavior without requiring a second environment feature flag.
The existing feature-gated behavior for providers without WithFinish() remains unchanged.
Attribute and View semantics
Finish accepts the complete input attributes associated with a recording operation, before any SDK View attribute filtering. Equivalent inputs are matched by value; slice identity and input order are irrelevant.
For every stream produced by the instrument:
- Resolve duplicate input keys with last-value-wins semantics.
- Apply that stream’s View attribute filter.
- Compute the filtered attributes’ identity using the same logic as recording.
- Finish that post-view series for the corresponding reader.
If a View causes multiple input attribute collections to collapse into one post-view set, finishing any one of those inputs finishes the combined post-view series. A later measurement contributing to that combined series cancels the pending finish if it has not yet been collected.
Partial, subset, and predicate matching are out of scope.
Canonicalization and ownership
attribute.Hasher requires attributes in ascending key order with duplicate keys removed. The SDK therefore needs canonical ordering, but it does not need to construct or retain an attribute.Set.
The implementation must not sort the variadic slice in place because Finish(ctx, attrs...) passes caller-owned storage. In-place sorting would be observable and could race with concurrent reuse.
The implementation should use a mutation-free canonical hashing path. Candidate strategies include:
- A linear fast path for already ordered input.
- A fixed-size stack scratch buffer for typical small inputs, with sorting and de-duplication performed on the scratch copy.
- A constant-space selection pass for larger inputs when retaining zero allocations is preferable to
O(n log n) sorting. This is O(n²) in the number of attributes and must be benchmarked.
The final strategy should be selected using benchmarks rather than fixed in the public API contract. Regardless of strategy, it must produce the same identity as recording and preserve last-value-wins behavior.
For each resolved stream, the View filter should be applied while hashing so that the SDK does not materialize a filtered set. The existing attribute set already retained by active aggregation state is reused when exporting the final point.
Cardinality overflow
A series mapped to the shared otel.metric.overflow aggregation cannot be individually identified after overflow processing.
Therefore:
- Finishing an input currently routed to the shared overflow series is a no-op.
Finish must never remove or reset the shared overflow aggregation.
- This behavior must be documented and tested.
Supported instruments and aggregations
The tracking issue is complete only when Finish is supported for all synchronous instruments:
Finish must work with every compatible synchronous aggregation:
- Cumulative and delta Sum
- LastValue
- Cumulative and delta explicit bucket Histogram
- Cumulative and delta exponential Histogram
- Drop, as a no-op
The first implementation should be an Int64Counter and Sum vertical slice before generalizing the implementation.
Multiple readers
Finish state is owned independently by each reader/pipeline:
- Each reader emits and releases its own final point.
- Collection by one reader must not remove another reader’s pending final point.
- A reader that never collects retains its pending state until provider shutdown.
- Provider shutdown releases any remaining pending Finish state.
- Export failure after collection does not restore the SDK aggregation state.
Concurrency requirements
Finish must be concurrency-safe and idempotent.
The following happens-before behavior is required:
- A measurement completed before
Finish begins is included in the pending final aggregation.
- A measurement started after
Finish returns cancels a still-pending finish and keeps the series active.
- A measurement concurrent with
Finish may be ordered on either side of it.
- A concurrent measurement must be aggregated exactly once.
- Collection racing with
Finish or reactivation must not lose or duplicate measurements.
Strict linearizability is not required for operations whose executions overlap.
Tests should cover Finish racing with recording, collection, repeated Finish calls, and reactivation.
Bind composition
Finish and experimental binding are independent capabilities:
sdkmetricx.WithFinish() and sdkmetricx.WithBinding() must be order-independent.
- The original synchronous instrument may implement both the appropriate Binder interface and
metricx.Finisher.
- This issue does not add a parameterless
Finish operation to bound instruments.
- Finishing through a bound instrument may be considered separately.
Implementation constraints
Finish-aware lifecycle aggregators should only be selected when WithFinish() is enabled.
The stable aggregation implementations and recording hot paths must not gain Finish-specific branches or state. Internal primitives may be shared when doing so adds no overhead to the stable path, but the implementation should not duplicate the entire Metrics SDK.
The Finish lookup path should:
- Treat the provided key-values as borrowed, immutable input.
- Normalize ordering and duplicate keys without mutating the input.
- Apply each stream’s View filter during hashing.
- Use
attribute.Hasher to compute the filtered attribute.Distinct.
- Avoid materializing either an input or filtered
attribute.Set when only identity is needed.
- Reuse the post-view attribute set already owned by active aggregation state for final export.
Performance requirements
Add benchmarks demonstrating the following:
- Providers without
WithFinish() have no new recording allocations and no material latency regression.
- Ordinary recording through an opted-in provider remains allocation-neutral when passed a prebuilt attribute set.
- The SDK portion of
Finish does not allocate for typical attribute counts on filtered and unfiltered streams.
Finish(ctx, attrs...) with a reused slice does not add a caller-side slice allocation.
- Inline variadic calls and reused-slice calls are reported separately because interface dispatch can change escape behavior.
- Input attributes are not modified.
Benchmark:
- Finishing an active series.
- Finishing an inactive or unknown series.
- Ordered, reverse-ordered, and duplicate-key inputs.
- Filtered and unfiltered streams.
- Repeated/idempotent Finish.
- Recording that cancels a pending Finish.
- Fan-out across multiple readers.
Report opted-in recording latency against the stable path using benchstat. This issue does not impose an arbitrary percentage threshold, but regressions must be explained and reviewed.
OBI compatibility and migration design
OBI’s current generic expiration path stores attribute.Set as the value of an ExpiryMap. The actual map key is not the set: ExpiryMap joins emitted attribute values with : and uses the resulting string as its key.
That representation has avoidable costs and ambiguity:
- It allocates and populates
[]string for attribute values.
- It calls
attribute.Value.Emit() for every value.
strings.Join constructs a variable-length string key.
- Unescaped delimiters permit deterministic collisions, such as
{"a:b", "c"} and {"a", "b:c"}.
- Type and input-order distinctions can disagree with the canonical series identity used by the Metrics SDK.
A downstream OBI refactor can instead key its OpenTelemetry expiration cache by attribute.Distinct and retain the key-values needed by the variadic Finish API:
type ExpiryMap[K comparable, V any] struct {
entries map[K]*entry[V]
}
// For the generic OpenTelemetry expirer:
ExpiryMap[attribute.Distinct, []attribute.KeyValue]
OBI already constructs key-values and an attribute.Set while processing a record. During an incremental migration it can obtain set.Equivalent() for the cache key, retain the key-values as the entry value, continue returning the set for recording, and call Finish(ctx, attrs...) at expiration.
This changes which representation is retained rather than requiring both representations for the primary generic expirer. Specialized runtime, Node.js, and target-info caches that currently reuse a set for recording can be migrated independently. They may temporarily retain both forms or restructure their cached series state based on measurement-path benchmarks.
attribute.Distinct is appropriate only as an in-memory cache key. It is probabilistic, not reversible, and not a stable persisted identifier. OBI must retain either the original key-values or an attribute set as the entry value.
Completion requires an API-level inspection or downstream prototype showing that OBI can:
- Retain the complete key-values for each expiring entity.
- Record the same logical attributes through the stable metric API.
- Type-assert the synchronous instrument to
metricx.Finisher.
- Call
Finish(ctx, attrs...) without reconstructing an attribute.Set.
- Do this for every synchronous instrument type currently provided by the fork.
This tracking issue does not require:
- An OBI migration or expiration-cache refactor PR.
- An in-repository OBI-shaped compatibility test.
- Exact preservation of the fork’s immediate-deletion semantics.
OBI’s current Remove operation deletes aggregation state immediately. The proposed Finish behavior instead preserves uncollected state for one final collection. Confirming that this semantic change is acceptable in OBI remains a downstream follow-up and must be called out when this issue is closed.
Implementation roadmap
Stage 1: Experimental API
Stage 2: SDK activation and lifecycle primitives
Stage 3: Int64Counter vertical slice
Stage 4: All synchronous instruments
Stage 5: Documentation and performance validation
Non-goals and deferred work
- Predicate or subset matching such as
FinishFn.
- A
FinishSet(context.Context, attribute.Set) method; consider a separate optional capability later if adoption requires it.
- Exposing
attribute.Distinct or attribute.Hasher as the Finish input.
- Finishing all series on an instrument in one call.
- Asynchronous instrument lifecycle management.
- Finishing through bound instrument handles.
- Removing an individual contribution from the shared overflow series.
- Export acknowledgements or retrying final points after collection.
- Staleness markers or
NoRecordedValue.
- Promotion into the stable Metrics API or SDK.
Risks and follow-up
- The specification proposal is still under development and may choose different matching, timestamp, or reactivation semantics.
- Attribute-filtering Views can merge multiple pre-view inputs into one series; exact finishing necessarily operates on that combined post-view identity.
- A reader that never collects retains pending state until shutdown.
- Global proxy instruments created before provider registration cannot dynamically acquire the optional Go interface.
- A variadic call with inline key-values is not guaranteed to avoid a compiler-generated allocation; callers requiring that property need a reusable slice.
- The canonicalization implementation must balance zero allocations against worst-case behavior for unusually large attribute collections.
- An OBI expiration-cache refactor is promising but remains unverified until downstream integration work is completed.
Problem statement
The Go Metrics SDK retains synchronous metric series after their last measurement. This is problematic for instrumentation that observes short-lived or dynamically changing entities: the SDK continues exporting their last aggregated value and retaining their aggregation state after those entities disappear.
OpenTelemetry specification issue #2232 tracks adding lifecycle support for these series, and specification PR #4702 proposes a
Finishoperation for synchronous instruments. The specification work remains in development, so this issue proposes an experimental Go API and SDK implementation that can evolve with it.A practical requirement is allowing OBI to replace its forked Go metric API and SDK. OBI currently adds a
Removeoperation to synchronous instruments and uses it to expire series for entities that have not been observed within a configured TTL.Previous prototypes include:
Goals
Finishcapability for an exact collection of attributes.attribute.Set.Removefunctionality.Experimental API
Add the following interface to
go.opentelemetry.io/otel/metric/x:Callers detect the capability using a type assertion:
The complete collection of attributes identifies one exact input series. It is not treated as a subset or predicate. Calling
Finish(ctx)identifies only the series recorded without attributes; it does not mean “finish every series.”Attribute order does not affect identity. Duplicate keys use the same last-value-wins semantics as recording.
Finishhas no return value. Finishing an unknown or unsupported series is a no-op.Implementations must not retain or modify the supplied attribute slice. This permits callers to reuse an existing slice safely and prevents an SDK implementation from sorting caller-owned storage in place.
Why
...attribute.KeyValueFinishonly needs to locate existing aggregation state. It does not need to store a new attribute set. Requiring anattribute.Setwould require callers that naturally retain key-values to construct a persistent representation solely so the SDK can reduce it to a lookup key.The variadic form:
Finish(ctx, attrs...)without a slice copy.attribute.Set.The variadic syntax does not guarantee that every call site performs zero allocations. In particular, a call with inline key-values through a dynamically dispatched optional interface may cause the compiler-generated variadic backing array to escape. The zero-allocation caller path is reuse of an existing slice. Performance claims and benchmarks must distinguish these two call forms.
Why not
attribute.Set,attribute.Distinct, or two methodsAn
attribute.Setremains useful when a caller needs a canonical immutable representation for repeated recording. It is not required for a Finish lookup, however, and constructing one can allocate.attribute.Distinctis not a suitable public input. It is not reversible, so the SDK could not apply per-stream View filters to the original attributes. It is also a probabilistic, process-local identity rather than a stable external identifier.This initial API will not additionally define
FinishSet(context.Context, attribute.Set). Two public methods for the same operation would increase the implementation and documentation surface before usage demonstrates that both are necessary. If adoption shows that callers commonly possess only anattribute.Set, a separate optional set-based capability can be considered without changing the existingFinishermethod set.SDK activation
Add an experimental SDK option in
go.opentelemetry.io/otel/sdk/metric/x:Only instruments created directly from a provider configured with
WithFinish()are required to implementmetricx.Finisher.Providers without
WithFinish()retain their existing behavior and implementation paths.Global provider limitation
Instruments obtained from the global MeterProvider proxy before the delegate provider is installed are not required to expose
metricx.Finisher.A Go value’s method set cannot gain an optional method after it has already been returned. Therefore, setting a finish-capable delegate later cannot make an existing proxy instrument satisfy the
Finisherinterface. This limitation must be documented in the package documentation and examples.Lifecycle semantics
For each resolved metric stream and reader, a series is in one of three conceptual states:
Finishing
Calling
Finishfor an active series marks that series as pending final.Repeated calls while the series remains pending are idempotent. They do not change the first finish timestamp.
At the next collection for each reader:
Timeis the time of theFinishcall, not the collection time.For delta aggregation,
Finishmust not synthesize a zero or empty point if there have been no measurements since the preceding collection. The reader may release that series state without emitting another point.“Collected” means returned by
MetricReader.Collect. It does not mean successfully exported. Export retries and delivery guarantees remain the responsibility of the reader and exporter.Reactivation before collection
If a measurement for the same post-view series arrives after
Finishbut before its final point is collected:If the final point has already been collected and forgotten, a later measurement creates a new series lifetime.
Start timestamps
Finish-aware aggregators must track per-series start timestamps independently of
OTEL_GO_X_PER_SERIES_START_TIMESTAMPS.The final cumulative point retains the original series start time. If the series is later recreated, its start time is the time of the first measurement in the new lifetime.
This is necessary to distinguish, for example, a recreated monotonic cumulative counter from an invalid decrease in the previous cumulative sequence. Enabling
WithFinish()must be sufficient to provide this behavior without requiring a second environment feature flag.The existing feature-gated behavior for providers without
WithFinish()remains unchanged.Attribute and View semantics
Finishaccepts the complete input attributes associated with a recording operation, before any SDK View attribute filtering. Equivalent inputs are matched by value; slice identity and input order are irrelevant.For every stream produced by the instrument:
If a View causes multiple input attribute collections to collapse into one post-view set, finishing any one of those inputs finishes the combined post-view series. A later measurement contributing to that combined series cancels the pending finish if it has not yet been collected.
Partial, subset, and predicate matching are out of scope.
Canonicalization and ownership
attribute.Hasherrequires attributes in ascending key order with duplicate keys removed. The SDK therefore needs canonical ordering, but it does not need to construct or retain anattribute.Set.The implementation must not sort the variadic slice in place because
Finish(ctx, attrs...)passes caller-owned storage. In-place sorting would be observable and could race with concurrent reuse.The implementation should use a mutation-free canonical hashing path. Candidate strategies include:
O(n log n)sorting. This isO(n²)in the number of attributes and must be benchmarked.The final strategy should be selected using benchmarks rather than fixed in the public API contract. Regardless of strategy, it must produce the same identity as recording and preserve last-value-wins behavior.
For each resolved stream, the View filter should be applied while hashing so that the SDK does not materialize a filtered set. The existing attribute set already retained by active aggregation state is reused when exporting the final point.
Cardinality overflow
A series mapped to the shared
otel.metric.overflowaggregation cannot be individually identified after overflow processing.Therefore:
Finishmust never remove or reset the shared overflow aggregation.Supported instruments and aggregations
The tracking issue is complete only when Finish is supported for all synchronous instruments:
Int64CounterFloat64CounterInt64UpDownCounterFloat64UpDownCounterInt64HistogramFloat64HistogramInt64GaugeFloat64GaugeFinish must work with every compatible synchronous aggregation:
The first implementation should be an
Int64Counterand Sum vertical slice before generalizing the implementation.Multiple readers
Finish state is owned independently by each reader/pipeline:
Concurrency requirements
Finishmust be concurrency-safe and idempotent.The following happens-before behavior is required:
Finishbegins is included in the pending final aggregation.Finishreturns cancels a still-pending finish and keeps the series active.Finishmay be ordered on either side of it.Finishor reactivation must not lose or duplicate measurements.Strict linearizability is not required for operations whose executions overlap.
Tests should cover Finish racing with recording, collection, repeated Finish calls, and reactivation.
Bind composition
Finish and experimental binding are independent capabilities:
sdkmetricx.WithFinish()andsdkmetricx.WithBinding()must be order-independent.metricx.Finisher.Finishoperation to bound instruments.Implementation constraints
Finish-aware lifecycle aggregators should only be selected when
WithFinish()is enabled.The stable aggregation implementations and recording hot paths must not gain Finish-specific branches or state. Internal primitives may be shared when doing so adds no overhead to the stable path, but the implementation should not duplicate the entire Metrics SDK.
The Finish lookup path should:
attribute.Hasherto compute the filteredattribute.Distinct.attribute.Setwhen only identity is needed.Performance requirements
Add benchmarks demonstrating the following:
WithFinish()have no new recording allocations and no material latency regression.Finishdoes not allocate for typical attribute counts on filtered and unfiltered streams.Finish(ctx, attrs...)with a reused slice does not add a caller-side slice allocation.Benchmark:
Report opted-in recording latency against the stable path using
benchstat. This issue does not impose an arbitrary percentage threshold, but regressions must be explained and reviewed.OBI compatibility and migration design
OBI’s current generic expiration path stores
attribute.Setas the value of anExpiryMap. The actual map key is not the set:ExpiryMapjoins emitted attribute values with:and uses the resulting string as its key.That representation has avoidable costs and ambiguity:
[]stringfor attribute values.attribute.Value.Emit()for every value.strings.Joinconstructs a variable-length string key.{"a:b", "c"}and{"a", "b:c"}.A downstream OBI refactor can instead key its OpenTelemetry expiration cache by
attribute.Distinctand retain the key-values needed by the variadic Finish API:OBI already constructs key-values and an
attribute.Setwhile processing a record. During an incremental migration it can obtainset.Equivalent()for the cache key, retain the key-values as the entry value, continue returning the set for recording, and callFinish(ctx, attrs...)at expiration.This changes which representation is retained rather than requiring both representations for the primary generic expirer. Specialized runtime, Node.js, and target-info caches that currently reuse a set for recording can be migrated independently. They may temporarily retain both forms or restructure their cached series state based on measurement-path benchmarks.
attribute.Distinctis appropriate only as an in-memory cache key. It is probabilistic, not reversible, and not a stable persisted identifier. OBI must retain either the original key-values or an attribute set as the entry value.Completion requires an API-level inspection or downstream prototype showing that OBI can:
metricx.Finisher.Finish(ctx, attrs...)without reconstructing anattribute.Set.This tracking issue does not require:
OBI’s current
Removeoperation deletes aggregation state immediately. The proposed Finish behavior instead preserves uncollected state for one final collection. Confirming that this semantic change is acceptable in OBI remains a downstream follow-up and must be called out when this issue is closed.Implementation roadmap
Stage 1: Experimental API
metricx.FinisherwithFinish(context.Context, ...attribute.KeyValue).Stage 2: SDK activation and lifecycle primitives
sdkmetricx.WithFinish().attribute.Hasher.Stage 3:
Int64Countervertical sliceStage 4: All synchronous instruments
Stage 5: Documentation and performance validation
benchstatresults.make precommit.Non-goals and deferred work
FinishFn.FinishSet(context.Context, attribute.Set)method; consider a separate optional capability later if adoption requires it.attribute.Distinctorattribute.Hasheras the Finish input.NoRecordedValue.Risks and follow-up