Observing

What the running wallet and ledger tell you in Grafana on kind: a send’s trace across both services in Tempo, their log records in Loki, their own metrics, humus’s and the Deployments’ in Mimir, the alert rules in deploy/alerts.yaml, and the dashboard in deploy/dashboard.json.

Opening Grafana

up.sh generates Grafana’s admin password into the grafana-admin Secret. Read it, then forward Grafana’s port:

kubectl --context kind-kind --namespace wallet get secret grafana-admin \
  --output jsonpath='{.data.password}' | base64 --decode
kubectl --context kind-kind --namespace wallet port-forward service/grafana 3000:3000

Sign in at http://localhost:3000 as admin.

A send’s trace

Send from alice’s account to bob’s, as Deploying shows. Each server span is named after its method, so in Explore, with Tempo, find the sends by name:

{resource.service.name="wallet" && kind=server && name="wallet.v1.Wallet/Send"}
SpanServiceWhat it is
wallet.v1.Wallet/Send, a server spanwalletthe send
GET, with server.address wiremockwalleta fetch of the key set, when the Verifier lacks the token’s key
ledger.v1.Ledger/Transfer, a client spanwalleteach attempt at the ledger, under the send
ledger.v1.Ledger/Transfer, a server spanledgerthe ledger’s answer, a child of the wallet’s attempt

So one trace holds both services. A test of the Apps checks it:

walletSpans := o.walletTel.Spans()
call := serverSpanNamed(t, walletSpans, "wallet.v1.Wallet/"+method)
traceID := call.SpanContext().TraceID()

attempts = spansNamed(walletSpans, trace.SpanKindClient, "ledger.v1.Ledger/Transfer", traceID)
require.NotEmpty(t, attempts, "the wallet has no attempt span in the %s's trace", method)
for _, attempt := range attempts {
	require.True(t, descends(attempt, call, walletSpans), "an attempt is not under the wallet's %s", method)
}

ledgerSpans = spansNamed(o.ledgerTel.Spans(), trace.SpanKindServer, "ledger.v1.Ledger/Transfer", traceID)
require.Len(t, ledgerSpans, len(attempts), "the ledger's server spans in the %s's trace", method)

A watch is one span on each side, wallet.v1.Wallet/WatchAccount and ledger.v1.Ledger/WatchAccount, from its opening to its end.

A rehearsed failure is in the trace too. A send whose scenario aborts postgresql:ledger, as the case TheLedgersDatabaseIsDown in The failure injection cases sends, has one attempt for each of the Client’s MaxAttempts:

err := o.send(withScenario(t, t.Context(), abort(ledgerPoint)), alice, bob, rand.Text(), 200)
requireCode(t, err, codes.Unavailable)
o.stop(t)

attempts, _ := requireOneTrace(t, o, "Send")
require.Len(t, attempts, grpcclient.DefaultSettings().MaxAttempts)

Each point a scenario reaches adds a humus.fit.evaluation event to its span, the ledger’s included:

{resource.service.name="ledger" && event:name="humus.fit.evaluation"}

Their log records

A record written in a call’s context carries its trace ID, and links to its trace in Tempo:

  - name: Loki
    uid: loki
    type: loki
    access: proxy
    url: http://loki:3100
    jsonData:
      derivedFields:
        - name: trace_id
          matcherType: label
          matcherRegex: trace_id
          datasourceUid: tempo
          url: "$${__value.raw}"
          urlDisplayLabel: Trace

In Explore, with Loki, the wallet’s records are:

{service_name="wallet"}

humus writes these:

RecordWritten by
serving, shutting down, stoppedthe App, as it starts and stops
listening, and a probe’s error at ERROR, with humus.health.probegrpchealth, on the probes port
authentication failed, at ERRORgrpcauth, when a call’s credentials are refused
RPC panicked, at ERRORgrpcserver
keys refresh failed, at WARNauth/jwt, when the key set can’t be fetched again
circuit breaker open, circuit breaker half-open, circuit breaker closedeach of the wallet’s Clients’ breakers, with the dependency’s server.address

The ledger writes the same, with service_name="ledger", but none of the last two: it calls nothing but PostgreSQL.

Each service writes one WARN record of its own, with rpc.method and exception.message: the wallet when it answers Unavailable for the ledger, and the ledger when a transaction fails:

func (s *Server) answer(ctx context.Context, err error) error {
	answered := answer(ctx, err)
	if status.Code(answered) == codes.Unavailable {
		s.warn(ctx, err)
	}
	return answered
}

func (s *Server) warn(ctx context.Context, err error) {
	method, _ := grpc.Method(ctx)
	var r log.Record
	r.SetTimestamp(time.Now())
	r.SetSeverity(log.SeverityWarn)
	r.SetSeverityText("WARN")
	r.SetBody(attribute.StringValue("the wallet answered Unavailable for the ledger"))
	r.AddAttributes(semconv.RPCMethod(strings.TrimPrefix(method, "/")), semconv.ExceptionMessage(err.Error()))
	s.logger.Emit(context.WithoutCancel(ctx), r)
}

So a send the ledger failed is linked from:

{service_name="wallet"} |= "the wallet answered Unavailable for the ledger"

No bearer token is on a span or a record, whole or in part. A test of the Apps checks both services:

parts := strings.Split(alice.token, ".")
secrets := map[string]string{"token": alice.token, "forged token": forged.token, "claims": parts[1], "signature": parts[2]}
for _, v := range recorded {
	for name, secret := range secrets {
		require.NotContains(t, v, secret, "the %s was recorded", name)
	}
}

Their metrics

InstrumentRecorded by
rpc.server.call.durationotelgrpc, in each service, for each call
rpc.client.call.durationotelgrpc, in the wallet, for each attempt at the ledger
http.client.request.durationthe wallet’s Client for the key set, for each attempt
humus.circuit.state, humus.circuit.calls, humus.circuit.transitions, humus.retry.decisionsthe wallet’s two Clients, scopes github.com/z5labs/humus/grpc/grpcclient and github.com/z5labs/humus/httpclient
ledger.transfers, by ledger.transfer.outcomethe ledger, for each transfer it answers: posted, replayed or refused
ledger.watchesthe ledger, the watches open on it
wallet.watches.reopenedthe wallet, each watch of the ledger’s it opened again

The ledger records its own two:

meter := tel.MeterProvider.Meter(scope)
transfers, err := meter.Int64Counter("ledger.transfers",
	metric.WithDescription("Transfers the ledger answered, by outcome."),
	metric.WithUnit("{transfer}"))
if err != nil {
	return nil, err
}
watches, err := meter.Int64UpDownCounter("ledger.watches",
	metric.WithDescription("Watches of an account open on the ledger."),
	metric.WithUnit("{watch}"))

A test of the Apps checks the outcomes, a repeated send included:

require.Equal(t, map[string]int64{"posted": 2, "replayed": 1, "refused": 1}, sums(t, o.ledgerTel, "ledger.transfers", "ledger.transfer.outcome"))

In Mimir, a name’s dots are underscores, and service.name is job:

sum by (rpc_method, rpc_response_status_code) (rate(rpc_server_call_duration_count{job="wallet"}[5m]))
sum by (ledger_transfer_outcome) (increase(ledger_transfers{job="ledger"}[5m]))

Each data point of the wallet’s Clients carries its dependency’s server.address, ledger or wiremock:

sum by (server_address, humus_circuit_state) (humus_circuit_state{job="wallet", otel_scope_name=~"github.com/z5labs/humus/(grpc/grpcclient|httpclient)"})

The Collector adds each pod’s attributes, so the two replicas of a service are told apart by k8s_pod_name:

  k8s_attributes:
    auth_type: serviceAccount
    pod_association:
      - sources:
          - from: connection
    extract:
      metadata:
        - k8s.namespace.name
        - k8s.pod.name
        - k8s.deployment.name
        - k8s.node.name

It reads the Deployments’ available replicas and the containers’ restarts from Kubernetes, since a service that is down reports nothing:

  k8s_cluster:
    auth_type: serviceAccount
    collection_interval: 10s
k8s_deployment_available{k8s_namespace_name="wallet", k8s_deployment_name="ledger"}
k8s_container_restarts{k8s_namespace_name="wallet", k8s_container_name="wallet"}

The alerts

deploy/alerts.yaml holds Grafana-managed alert rules, in the folder Wallet. The kind overlay provisions them:

  - name: grafana-alerting
    files:
      - ../alerts.yaml
AlertSeverityWhen
WalletErrorspagethe wallet has ended over 1% of its unary calls with a server’s code for 10 minutes
WalletSlowpagethe wallet’s p99 latency of unary calls has been over 1s for 10 minutes
LedgerErrorspagethe ledger has ended over 1% of its unary calls with a server’s code for 10 minutes
WalletBreakerOpenpagea breaker of the wallet’s is open: the ledger’s, or the key set’s
WalletBreakerOpenedticketa breaker of the wallet’s opened in the last 5 minutes, however briefly
WalletRetriesThrottledpagea Client’s retries were throttled in the last 5 minutes
WalletUnavailable, LedgerUnavailablepagethe Deployment has had no available replica for 5 minutes
WalletCrashLooping, LedgerCrashLoopingpagea container restarted more than 3 times in 15 minutes

Each rule is a query of Mimir and a threshold. A server’s codes are those grpcserver marks its spans Error for. WatchAccount is left out, since a watch’s duration is how long it was open, and every rollout ends it with Unavailable:

      - uid: wallet-errors
        title: WalletErrors
        condition: B
        data:
          - refId: A
            relativeTimeRange:
              from: 600
              to: 0
            datasourceUid: mimir
            model:
              refId: A
              instant: true
              expr: ((sum(rate(rpc_server_call_duration_count{job="wallet", rpc_method!="wallet.v1.Wallet/WatchAccount", rpc_response_status_code=~"UNKNOWN|DEADLINE_EXCEEDED|UNIMPLEMENTED|INTERNAL|UNAVAILABLE|DATA_LOSS"}[5m])) or vector(0)) / sum(rate(rpc_server_call_duration_count{job="wallet", rpc_method!="wallet.v1.Wallet/WatchAccount"}[5m])) >= 0) or vector(0)
          - refId: B
            datasourceUid: __expr__
            model:
              refId: B
              type: threshold
              expression: A
              conditions:
                - evaluator:
                    type: gt
                    params: [0.01]
        noDataState: OK
        execErrState: Error
        for: 10m
        labels:
          severity: page

The breakers’ and retries’ rules keep server_address, so an alert says which dependency:

              expr: max by (server_address) (humus_circuit_state{job="wallet", otel_scope_name=~"github.com/z5labs/humus/(grpc/grpcclient|httpclient)", humus_circuit_state="open"}) or on () vector(0)

A counter’s rule adds the series that first appeared in its window, which increase alone reads as no change:

              expr: (sum by (server_address) (increase(humus_circuit_transitions{job="wallet", otel_scope_name=~"github.com/z5labs/humus/(grpc/grpcclient|httpclient)", humus_circuit_state="open"}[5m]) or (humus_circuit_transitions{job="wallet", otel_scope_name=~"github.com/z5labs/humus/(grpc/grpcclient|httpclient)", humus_circuit_state="open"} unless humus_circuit_transitions{job="wallet", otel_scope_name=~"github.com/z5labs/humus/(grpc/grpcclient|httpclient)", humus_circuit_state="open"} offset 5m))) or on () vector(0)

The dashboard

deploy/dashboard.json is the Wallet dashboard, at http://localhost:3000/d/wallet. The kind overlay provisions it from a file, so it can’t be changed in Grafana:

apiVersion: 1
providers:
  - name: wallet
    orgId: 1
    folder: Wallet
    type: file
    disableDeletion: true
    allowUiUpdates: false
    options:
      path: /etc/grafana/dashboards
PanelWhat it shows
Wallet calls by method and codethe wallet’s calls, by method and code
Ledger calls by method and codethe ledger’s calls, by method and code
Wallet latency of unary callsthe wallet’s p50 and p99, without WatchAccount
Ledger latency of unary callsthe ledger’s p50 and p99, without WatchAccount
Open watchesthe watches open on the ledger
Watches opened againthe ledger’s watches the wallet opened again
Transfers by outcomethe ledger’s transfers, posted, replayed or refused
Wallet breakerseach of the wallet’s breakers, by state
Wallet breaker transitionseach of the wallet’s breakers’ changes of state
Wallet retry decisionseach of the wallet’s Clients’ retries, by decision
Available replicaseach Deployment’s available replicas
Restartseach container’s restarts

Each panel is a query of Mimir:

      "targets": [
        {
          "refId": "A",
          "datasource": {
            "type": "prometheus",
            "uid": "mimir"
          },
          "expr": "sum by (rpc_method, rpc_response_status_code) (rate(rpc_server_call_duration_count{job=\"wallet\"}[$__rate_interval]))",
          "legendFormat": "{{rpc_method}} {{rpc_response_status_code}}"
        }
      ]