Repository navigation
Replies: 4 comments
Revision 1: the open questions answered, and every name starts with
|
| In the post | Now |
|---|---|
circuit.state, the instrument and its attribute |
humus.circuit.state |
circuit.transitions |
humus.circuit.transitions |
circuit.calls |
humus.circuit.calls |
retry.decisions |
humus.retry.decisions |
circuit.previous_state |
humus.circuit.previous_state |
circuit.outcome |
humus.circuit.outcome |
retry.decision |
humus.retry.decision |
the counts on the breakers' log records: circuit.successes, circuit.failures, circuit.consecutive_successes, circuit.consecutive_failures and circuit.timed_out |
humus.circuit.successes, humus.circuit.failures, humus.circuit.consecutive_successes, humus.circuit.consecutive_failures and humus.circuit.timed_out |
The dependency's attributes are OpenTelemetry's, server.address, server.port and messaging.system, and keep their names. So do the records' bodies, such as circuit breaker open.
In Prometheus, the instruments are humus_circuit_state, humus_circuit_transitions_total, humus_circuit_calls_total and humus_retry_decisions_total, and the attributes are labels such as humus_circuit_state. The post's query for every opening, in humus.circuit.transitions, becomes increase(humus_circuit_transitions_total{humus_circuit_state="open"}[5m]).
Summary
The table becomes:
| Instrument | Kind | Unit | Attributes, beside the dependency's | Recorded |
|---|---|---|---|---|
humus.circuit.state |
asynchronous UpDownCounter | {breaker} |
humus.circuit.state: closed, open or half-open |
at each collection, from Breaker.State |
humus.circuit.transitions |
Counter | {transition} |
humus.circuit.state and humus.circuit.previous_state |
at each change of the breaker's state |
humus.circuit.calls |
Counter | {call} |
humus.circuit.outcome: success, failure, ignored or rejected |
at each call the breaker counts or rejects |
humus.retry.decisions |
Counter | {decision} |
humus.retry.decision: again, succeeded, stopped, exhausted, throttled or expired |
after each attempt; not by the Kafka Producer |
The first bullet and the bullet on the tokens become:
- Every name starts with
humus., thencircuit.for the breaker andretry.for the retries. The breakers' log records rename their attributes to match. Every attribute value is theString()of bedrock's own type:circuit.State,circuit.Outcomeandretry.Decision. - The retry budget's tokens aren't exported. A retry the budget refused is a
throttleddecision, counted exactly, and the tokens mean nothing withoutMaxTokens.
The instruments
The block becomes:
humus.circuit.state Int64ObservableUpDownCounter {breaker}
"Circuit breakers in each state. Only the current state's value is non-zero."
humus.circuit.transitions Int64Counter {transition}
"Changes of state of a circuit breaker."
humus.circuit.calls Int64Counter {call}
"Calls through a circuit breaker, by how each counted."
humus.retry.decisions Int64Counter {decision}
"What retry decided after each attempt."
The sentence before the attributes' table becomes:
humus.circuit.previous_state is on the breakers' log records already, renamed from circuit.previous_state. The other three attributes are new.
What isn't measured
The bullets on the retry budget's tokens and on the probes end without "Open question 1." and "Open question 3.": both stay unmeasured.
Design decisions
The bullets "The circuit. and retry. namespaces, with bedrock's words" and "No name for a client" become:
- The
humus.prefix, then bedrock's words. OpenTelemetry recommends a prefix for names it doesn't define, and libraries that define their own use their own name: gRPC, Resilience4j and Polly among them. In Prometheus, where the scope is only a label, the prefix keeps humus's series apart from another library'scircuit_state. After the prefix the names follow the breakers' log records, which take the prefix too. The attribute values are bedrock'sString()s, so aDecisionin the code and in a query read the same. - No name for a client. A client is told apart by its dependency's attributes, as its log records are. Clients of one dependency that keep to its contract see the same failures, so two clients of one origin add up. The rare service with two, such as two APIs behind one gateway, sees both in one series, and their transitions in one log stream. A name can come when users ask for one.
Testing
In the sketches, every instrument's name and every key takes the prefix: points(t, rm, "humus.circuit.calls") returns "humus.circuit.outcome=failure": 5, and so on.
This is added to the tests the stories add:
- The records' names. Each client's transition records carry
humus.circuit.previous_stateand the five counts, and no attribute whose name starts withcircuit..
Changes to other files
These are added:
- The breakers' log records, in all four clients, rename
circuit.previous_stateand the five counts as Names shows. Their bodies don't change. - The four clients' docs pages and package docs rename them in their lists of the records' attributes.
Migration from today
The second row becomes:
| Before the reset | After wave 4 | With this proposal |
|---|---|---|
| no breakers | a breaker's state in WARN and INFO log records only, with circuit. attributes |
humus.circuit.state and humus.circuit.transitions, beside the records, whose attributes become humus.circuit. |
The other rows take the names of Names. There is still nothing for the end user to migrate: no release carries the circuit. attributes.
Open questions
All four are answered above.
Out of scope
"A name for a client in its telemetry, unless open question 2 is answered yes" becomes:
- A name for a client in its telemetry, until users ask for one.
Revision 2:
|
| In the post | Now |
|---|---|
health.probe, on httphealth's and grpchealth's records of a monitor's error |
humus.health.probe |
Changes to other files
This is added:
httphealthandgrpc/grpchealthrenamehealth.probetohumus.health.probeon their records of a monitor's error, in their package docs and on the health docs page.
health itself doesn't change: it writes no records.
Revision 3: the Kafka consumer's own attributes take the prefix tooRevision 1 put
They become:
Their values, the records' bodies and their severities don't change. The records' attributes from OpenTelemetry's conventions keep their names: Changes to other filesThis is added:
|
Closing: humus's own metrics have landedThe revisions' stories are done:
Revisions 1 to 3 left no open questions. The retry budget's tokens aren't exported, a client gets no name of its own, probe answers aren't counted, and every name humus defines starts with The rest is out of scope or belongs to other proposals, as "Out of scope" lists:
|
Uh oh!
There was an error while loading. Please reload this page.
Note
Part of the humus re-evaluation; see Proposal: humus on the bedrock process model, wave 5, which revision 9 added. It builds on Proposal: OpenTelemetry without globals and Proposal: humustest, and on the four wave-4 clients whose breakers and retries it measures: outbound HTTP clients, outbound gRPC clients, producing to Kafka and RabbitMQ. It depends on no other wave-5 proposal: failure injection and authentication and authorization decide their own telemetry. It adds an instrument to no package outside those four clients. It answers the question outbound HTTP clients' revision 1 left for every client at once: whether humus records instruments of its own.
Summary
Each of the four wave-4 clients records four instruments of humus's own, for its circuit breaker and its retries. Nothing else in humus records one.
circuit.state{breaker}circuit.state:closed,openorhalf-openBreaker.Statecircuit.transitions{transition}circuit.stateandcircuit.previous_statecircuit.calls{call}circuit.outcome:success,failure,ignoredorrejectedretry.decisions{decision}retry.decision:again,succeeded,stopped,exhausted,throttledorexpiredProducercircuit.namespace of the breakers' log records, andretry.beside it. Every attribute value is theString()of bedrock's own type:circuit.State,circuit.Outcomeandretry.Decision.server.addressandserver.port, withmessaging.systemtoo for Kafka.throttleddecision, counted exactly, and the tokens mean nothing withoutMaxTokens. Open question 1.humustest.Telemetry.Metrics.Background
The clients left it here. Outbound HTTP clients asked in its open question 4 whether the clients should export a breaker's state and a budget's tokens. Its revision 1 answered no for wave 4, because the question is wider than one client. The charter's revision 9 made it a proposal of its own, and outbound gRPC clients, producing to Kafka and RabbitMQ named it in their revisions. The doc comments of
grpcclientandrabbitmq, and the docs pages of all four clients, point here.bedrock expects a framework to export these.
circuitlistsStateas "whenever, such as in a metrics callback", and makes it report half-open "as soon as the open period has passed, with no traffic" so that a gauge shows it.retrylistsBudget.Tokensthe same way.circuitobserves no results or rejections: "A framework counts them by wrapping the classifier and by checking for ErrRejected" (circuit v0.24.0).retry'sAttempt.Donereturns aDecisionfor every attempt, which says why no attempt follows:Succeeded,Stopped,Exhausted,ThrottledorExpired(retry v0.24.0).queue/kafkapinscircuitv0.25.0 and the other three modules v0.24.0; the two releases ofcircuitare identical.What each client records today. Each records OpenTelemetry's instruments for its protocol, and logs its breaker's transitions. Nothing records the breaker's state, and only one client counts its rejections:
httpclientotelhttp'shttp.client.request.durationandhttp.client.request.body.size, per attempthttp.client.request.duration, witherror.typegrpcclientotelgrpc'srpc.client.call.duration, per attemptkafka.Producermessaging.client.sent.messagesandmessaging.client.operation.duration, per record, and kotel's broker countersproducer.go#L262)rabbitmq.Publishermessaging.client.sent.messagesandmessaging.client.operation.duration, per attempt that reaches a channelThe transitions are log records at WARN and INFO, with
circuit.previous_stateand the counts of the state the breaker left (httpclient/breaker.go#L72,grpcclient/breaker.go#L105,kafka/breaker.go#L68,rabbitmq/breaker.go#L69). An alert on an open breaker is a log query today.OpenTelemetry has no conventions for circuit breakers or retries. Each library names its own. MicroProfile Fault Tolerance, for example, records
ft.circuitbreaker.calls.totalwithcircuitBreakerResult(success,failureorcircuitBreakerOpen),ft.circuitbreaker.opened.total, andft.retry.calls.totalwithretryResult(Open Liberty's list). OpenTelemetry's naming rules apply all the same: a namespace isn't plural, a counter's name is plural when its unit is an annotation such as{call}, nothing ends in_total, and a metric "MAY share the same name" as an attribute. Its Kubernetes metrics give the shape for a state:k8s.container.status.stateis an UpDownCounter in{container}, with an attribute of the same name, of which "only the value corresponding to the current state will be non-zero".Before the reset, humus recorded two instruments of its own, both in
queue/kafkaand both on OpenTelemetry's global meter:messaging.client.messages.processedandmessaging.client.messages.committed(queue/kafka/otel.goat v0.15.0). The Kafka runtime replaced them in wave 3 with OpenTelemetry's messaging instruments. humus had no breaker or retries before wave 4.Proposed API
No exported identifier is added, removed or changed. The instruments are the API: their names, kinds, units, descriptions and attributes, which dashboards and alerts depend on, and which change only through a proposal.
The instruments
Each client creates them from
s.Telemetry.MeterProvider.Meter(<its import path>), in its constructor:circuit.previous_stateis on the breakers' log records already. The other three attributes are new:circuit.stateclosed,open,half-opencircuit.State.String(): the state a data point counts, or the state a transition went tocircuit.previous_statecircuit.outcomesuccess,failure,ignored,rejectedcircuit.Outcome.String()for a call the breaker counted, andrejectedfor one it didn't admitretry.decisionagain,succeeded,stopped,exhausted,throttled,expiredretry.Decision.String()The dependency's attributes
Every data point carries the attributes that name the client's dependency. They are those of the breaker's log records, so a series and a record join on them:
httpclientgithub.com/z5labs/humus/httpclientserver.addressandserver.portofBaseURL's origingrpcclientgithub.com/z5labs/humus/grpc/grpcclientserver.addressandserver.portofAddrkafka.Producergithub.com/z5labs/humus/queue/kafkamessaging.systemkafka, andserver.addressandserver.portof the first ofProducerSettings.Brokersrabbitmq.Publishergithub.com/z5labs/humus/queue/rabbitmqserver.addressandserver.portofConnection.AddrThe Kafka
Producer's log records carrymessaging.systemalone today, so two Producers for two clusters, which producing to Kafka's revision 1 allows, can't be told apart. Its transition records gainserver.addressandserver.portof the first broker too.CheckBrokersaccepts onlyhost:port, so the first broker always has both. It is the seed the end user named first, not the broker that answered: the breaker guards the cluster, and the seed is what names it in the settings.The Kafka and RabbitMQ scopes already hold OpenTelemetry's messaging instruments, which these join. The HTTP and gRPC scopes hold no instrument today:
otelhttpandotelgrpcrecord in their own.Which client records which
circuit.statecircuit.transitionscircuit.callsretry.decisionshttpclientgrpcclientkafka.ProducerProducerabbitmq.PublisherThe RabbitMQ
Serviceretries its connection with aretry.Retrierthat has no budget and only ends withServe's context. It records no decisions: it isn't on the request path, and each failed attempt is already a WARN record,connecting failed(service.go#L395).circuit.stateAt each collection, the callback reads
Breaker.State()once and observes all three states: 1 for the current one and 0 for the other two. Every state is reported every time, so a series never goes stale while the client exists, andsum by (circuit_state)counts the breakers in each.Statereports the state "as time has left it": onceOpenForhas passed, it reads half-open with no call made, although the breaker makes the transition, and the observer logs it, only on the next call. So the instrument shows half-open as soon as the breaker would admit a probe. It calls nothing of the end user's and changes nothing.{breaker}, not a gauge. Its values add up: two clients with the same dependency attributes, which outbound HTTP clients' revision 1 allows when two dependencies share an origin, reportclosed1 andopen1 rather than one hiding the other. The Go SDK sums the observations that two callbacks make for one attribute set of an asynchronous UpDownCounter, and keeps only the last for a gauge; the prototype checked both.Meter.RegisterCallback, not with a callback option. The SDK keeps the callback option of an instrument's first creation only, and drops the others with a warning, "Repeated observable instrument creation with callbacks. Ignoring new callbacks" (go.opentelemetry.io/otel/sdk/metricv1.47.0). The second client of a package in an App creates the instrument again, so its callback would be lost. The prototype hit this.App.Runshuts it down after theOnStopfunctions (app.go#L218-L223). So the final export carries each breaker's state as the App left it, such as a breaker that was open when the pod stopped, and a test reads it after the App has exited.Stateis safe to call once the client is closed.circuit.transitionsOne increment for each
circuit.Transition, in the observer that writes its log record, withcircuit.stateset to the state it went to andcircuit.previous_stateto the one it left. So each log record has one increment, and the two carry the same attributes.The state alone misses short opens. The OTLP exporters collect every minute, and
OpenForis 10s by default, so a breaker can open, go half-open and close between two collections, andcircuit.statenever shows it open.rate(circuit_transitions{circuit_state="open"})counts every opening.circuit.callsOne increment for each call the breaker counted, with the outcome the classifier gave it, and one for each call it rejected, with
rejected. A call is whatcircuitcalls a call, oneAllow: an attempt for HTTP, gRPC and RabbitMQ, and oneProducefor Kafka, whatever its number of records.circuit's doc says to. Each client already passescircuit.Newa classifier of its own (countVerdict,breakerOutcome,classify,classifyBreaker), and the wrapper records what it returns.Ticket.Doneruns the classifier before it settles the ticket, so a late result, which the breaker then discards, is counted with its outcome too. A ticket settled byRelease, such as a stream whose caller cancelled it or an attempt that panicked, reaches no classifier and counts nothing, as it counts nothing in the breaker.Allow's error, before it returns the rejection.Producereturns.Classify, and this is the only place its verdicts show: a 409 their classifier counts as a failure is a 4xx inotelhttp's instrument. And a rising share offailureis what trips the breaker, before it opens.otelhttp'shttp.client.request.duration, whoseerror.typethey already carry. That keeps one query for the rejections of all four clients.retry.decisionsOne increment after each
Attempt.Done, with theDecisionit returned. Every attempt has exactly one, rejected attempts included, whichretrydecidesStopped.againis whatretrydecided, not what the client then did, asretry's doc says ofAgain. Another attempt doesn't follow when:Done;Donetakes a token from the budget for a non-idempotent request'sRetryall the same, since the budget counts the dependency's failures, so counting itsagainkeeps the instrument in step with the budget. The span of each attempt the client did make,http.request.resend_countincluded, is unchanged.A call ends on one decision other than
again, unless it ends in one of the two ways above. Sothrottledcounts the retries the budget refused,exhaustedthe calls that used every attempt, andexpiredthe calls whose deadline came before the next attempt could start.What isn't measured
An instrument of humus's own measures state that humus decides, that no OpenTelemetry instrumentation or the orchestrator measures already, and that changes many times over a process's life. Each package the charter named was decided against that rule:
throttledcounts the budget refusing a retry, exactly, while a gauge sampled every minute misses a budget that empties and refills between collections. The tokens also mean nothing withoutMaxTokens, which would take a second instrument,retry.budget.limit. Open question 1.prober_probe_totalbyprobe_typeandresult, and the pod's Ready condition is the readiness itself. A probe attached to the end user's server is already inotelhttp's orotelgrpc's server instruments, with its 503. A probes server on a port of its own records nothing, on purpose (httphealth/service.go#L78). An asynchronous instrument would check the monitors at each collection, which costs a check and can change state:grpcclient'sConnectedstarts the connection. A monitor's error is already an ERROR record withhealth.probe. Open question 3.serving,shutting downandstopped, itsShutdownCauseand its exit status.jobspan, and its exit status is what the orchestrator counts, as a Kubernetes Job's completions and failures.service.go#L837).lost the connection(service.go#L1026), as is each failed connect, and it is rare.grpcclient'sConnected, the servers,openapi/v3andtelemetry. No instrument.otelhttpandotelgrpcmeasure the servers andopenapi's answers, by status code, andConnectedlatches once.In a test
A test reads the instruments with
humustest.Telemetry.Metrics(ctx), as it reads any metric. While the App runs,Metricscollects from itsManualReader, which runs every callback, socircuit.statereads the breaker at that moment. Once the App has exited,Metricsreturns what the SDK'sShutdowncollected, after theOnStopfunctions ran, so the state is still there. humustest gains nothing: a test filters the scope and the name itself, with a helper of its own like the prototype'spoints.Design decisions
retrydoes. Every other package's state is measured by OpenTelemetry's instrumentation or the orchestrator, or happens once. See What isn't measured.circuit.andretry.namespaces, with bedrock's words. The charter asks for names in line with the breakers' log records, which usecircuit.. The attribute values are bedrock'sString()s, so aDecisionin the code and in a query read the same. OpenTelemetry recommends a prefix for names it doesn't define; the instrumentation scope already names humus on every data point. Open question 4.rpc.method, nohttp.request.method, nomessaging.destination.name. The breaker and the budget are one per dependency, so a method or a topic would only split one breaker's counts, and grow with the API.otelhttp,otelgrpcand the messaging instruments break calls down by method and destination already.Builder, whichtelemetry.SDK's doc allows (sdk.go#L27), drops or renames them. humus adds no setting to turn them off.circuit.state's callback lives as long as the App's telemetry, which ends after theOnStopfunctions, so the last export has the state the App stopped in.retry's decisions. Whatever Proposal: failure injection decides an injected abort counts as, they count it that way, with no change to either proposal.Testing
A prototype passed the tests below under
go test -race -count=3, on main at 862a82f5, Go 1.27.0, OpenTelemetry's SDK v1.47.0 and bedrock'scircuitandretryv0.24.0. It added the four instruments tohttpclient: a file of its own, a wrapper aroundcountVerdict, the transition counter beside the log record, the rejection in the breaker layer and the decision in the retry layer. Each test runs the end user's whole App inside atesting/synctestbubble, on a humustestHost, with a fake dependency served byhttpserveron the same Host over TLS, certificates fromhumustest.NewCA, and the client's existing test helpers.OpenFortakes no real time. The prototype was in a scratchpad and isn't kept; the sketches are trimmed from it.pointscollects the data points of one instrument in the client's scope, keyed by their attributes other than the dependency's, which it checks:The instruments follow the breaker through open, half-open with no call made, and closed, and the state is still there after the App has exited:
A retry the budget refuses is counted. With a budget of 3 tokens and a dependency that answers 503 to everything, the first call's first 503 takes a token and is retried, its second takes another and is throttled, and the second call's first 503 takes the last:
Two clients of one origin add up. One App builds two clients for
https://payments:8443, opens the first one's breaker and leaves the second's closed, and readscircuit.statewhile its job runs:That test first failed, with
closed0 andopen1: the prototype had given the state's callback withmetric.WithInt64Callback, and the SDK had dropped the second client's. A separate test against the SDK alone showed the rest: a callback option on a repeated creation is ignored, two callbacks registered withRegisterCallbackthat observe one attribute set are summed for an asynchronous UpDownCounter, and a gauge keeps the last one's value. WithRegisterCallback, the test passed.The stories add the tests the prototype didn't have:
grpcclient. A unary call and a stream open, through retries and the breaker, with the same expectations as above. A stream counts its outcome when it ends, and a stream its caller cancels counts nothing. A rejected attempt countsrejectedand decidesstopped.kafka.Producer. Onecircuit.callsincrement perProduce, whatever its number of records. A call the breaker rejects countsrejectedonce. A call whose records failed for their topic's own reason countsignored. The instruments and the transition records carryserver.addressandserver.portof the first broker, and two Producers for two clusters have two series. Noretry.decisionsis recorded. These run on kfake, as the Producer's tests do.rabbitmq.Publisher. The same as HTTP's, as unit tests on the stand-in that*amqp.Channelsatisfies, insidesynctest, with no broker. The container tests are unchanged.circuit.statewith no data point.MeterProviderset to a recorder of its own, nothing is recorded in it.httpclient'sTestClient_measures_each_attemptandgrpcclient's telemetry test assert that "humus records no instrument of its own". The prototype's change failed the first of them, as expected. Each now checks that its package's scope holds the four instruments and nothing else.Each package stays held to the 80% coverage gate.
Changes to other files
This proposal's stories make these, as it owns humus's own metrics:
httpclient,grpc/grpcclient,queue/kafkaandqueue/rabbitmqrecord the instruments, in a file of their own in each package, and their doc comments' telemetry sections list them.httpclient'sNewClientadds the Setup'sMeterProviderto the nil check it makes of itsLoggerProvider.queue/kafka's Producer addsserver.addressandserver.portof the first broker to its transition records.grpc,queue/kafkaandqueue/rabbitmqmodules'go.modlistgo.opentelemetry.io/otel/metricas a direct requirement. Each has it already, as an indirect one.http-client.md,grpc-client.md,kafka.mdandrabbitmq.md, replace "humus records no instrument of its own" and the pointers to this proposal with what each client records, and an example alert oncircuit.state.humustest,telemetry,health, the servers and the consumers don't change.Migration from today
messaging.client.messages.processedand.committedinqueue/kafka, on OpenTelemetry's global meterMeterProvidercircuit.stateandcircuit.transitions, beside the recordsotelhttp's instrument for HTTP, and on spans for the other threecircuit.callswithrejected, for all fourretry.decisions, withthrottled,exhaustedandexpiredmessaging.systemonlyserver.addressandserver.portof the first brokerThere is nothing for the end user to migrate. The instruments appear on the next upgrade, and a View in their
Builderdrops any they don't want.Open questions
retry.decisionscounts every retry the budget refuses asthrottled, exactly, which is what an alert needs. A tokens gauge shows how close a budget is to refusing, but only next toMaxTokens, which would be a second instrument,retry.budget.limit, and both are sampled once a minute, while a budget can empty and refill between collections. As UpDownCounters, as the state is, two budgets with the same attributes would add up. Recommendation: no.Peer stringin each client's settings, could put OpenTelemetry'sservice.peer.nameon its instruments and its breaker's records, for two APIs behind one gateway. That is a field in four settings structs for a case wave 4 allowed but no user has reported. Recommendation: no.humus.? OpenTelemetry recommends that a name it doesn't define carry a prefix of its own, so it can't clash with a convention it defines later. As proposed, the instruments usecircuit.andretry., as the breakers' landed log records do, and the instrumentation scope names humus on every data point. A prefix would rename the records'circuit.attributes too, which nothing has released yet. Recommendation: keepcircuit.andretry..Out of scope
References
telemetry.SDK, andhumustest.Telemetry'sManualReaderand final collectioncircuitandretryv0.24.0:Breaker.State, the classifier,Transition,Attempt.Done,DecisionandBudget.Tokensk8s.container.status.stateMeter.RegisterCallback, and its warning on repeated callback optionsprober_probe_totalqueue/kafka/otel.goat v0.15.0All reactions