17 Commits

Author SHA1 Message Date
renovate d2510ff66f chore(deps): update actions/checkout action to v7
renovate/stability-days Updates have met minimum release age requirement
otelsetup / vulnerabilities (pull_request) Successful in 1m37s
otelsetup / test (pull_request) Successful in 2m30s
pre-commit / pre-commit (pull_request) Successful in 10m41s
2026-06-20 18:09:59 +00:00
releaser 4732aa5ab2 chore(release): prepare for v0.4.0 (#151)
Release / release (push) Successful in 57s
otelsetup / vulnerabilities (push) Successful in 1m29s
otelsetup / test (push) Successful in 2m31s
pre-commit / pre-commit (push) Successful in 5m11s
## [0.4.0] - 2026-06-16

### 🚀 Features

- *(metrics)* Add SubscriptionMetrics OTel observer for subscriptions (#150)

### 🐛 Bug Fixes

- *(deps)* Update opentelemetry-go monorepo (#146)
- *(deps)* Update module github.com/99designs/gqlgen to v0.17.91 (#148)

<!-- generated by git-cliff -->

---

**Note:** Please use **Squash Merge** when merging this PR.

Reviewed-on: #151
Co-authored-by: Unbound Releaser <releaser@unbound.se>
Co-committed-by: Unbound Releaser <releaser@unbound.se>
2026-06-16 13:22:52 +00:00
argoyle c00564cdcb feat(metrics): add SubscriptionMetrics OTel observer for subscriptions (#150)
Release / release (push) Successful in 1m31s
otelsetup / vulnerabilities (push) Successful in 2m8s
otelsetup / test (push) Successful in 2m47s
pre-commit / pre-commit (push) Successful in 6m47s
2026-06-16 13:14:14 +00:00
renovate 2836af0242 fix(deps): update module github.com/99designs/gqlgen to v0.17.91 (#148)
otelsetup / vulnerabilities (push) Successful in 1m39s
otelsetup / test (push) Successful in 2m24s
Release / release (push) Successful in 1m0s
pre-commit / pre-commit (push) Successful in 6m2s
2026-06-14 15:33:23 +00:00
renovate 212369811f fix(deps): update opentelemetry-go monorepo (#146)
Release / release (push) Failing after 1m3s
otelsetup / vulnerabilities (push) Successful in 1m39s
otelsetup / test (push) Successful in 2m23s
pre-commit / pre-commit (push) Successful in 6m1s
2026-05-31 12:07:55 +00:00
releaser afac050279 chore(release): prepare for v0.3.1 (#145)
Release / release (push) Successful in 52s
otelsetup / vulnerabilities (push) Successful in 1m51s
otelsetup / test (push) Successful in 2m27s
pre-commit / pre-commit (push) Successful in 6m30s
## [0.3.1] - 2026-05-29

### 🐛 Bug Fixes

- Set service.instance.id for unique instance label (#144)

<!-- generated by git-cliff -->

---

**Note:** Please use **Squash Merge** when merging this PR.

Reviewed-on: #145
Co-authored-by: Unbound Releaser <releaser@unbound.se>
Co-committed-by: Unbound Releaser <releaser@unbound.se>
2026-05-29 06:47:04 +00:00
argoyle afa847c76c fix: set service.instance.id for unique instance label (#144)
Release / release (push) Successful in 54s
pre-commit / pre-commit (push) Successful in 6m51s
otelsetup / vulnerabilities (push) Failing after 10m48s
otelsetup / test (push) Failing after 10m50s
2026-05-29 06:39:14 +00:00
releaser fff76c4acc chore(release): prepare for v0.3.0 (#143)
Release / release (push) Successful in 43s
otelsetup / vulnerabilities (push) Successful in 1m40s
otelsetup / test (push) Successful in 2m29s
pre-commit / pre-commit (push) Successful in 6m17s
## [0.3.0] - 2026-05-26

### 🚀 Features

- Add eventsourced MetricsRecorder adapter for OpenTelemetry (#142)

<!-- generated by git-cliff -->

---

**Note:** Please use **Squash Merge** when merging this PR.

Reviewed-on: #143
Co-authored-by: Unbound Releaser <releaser@unbound.se>
Co-committed-by: Unbound Releaser <releaser@unbound.se>
2026-05-26 18:04:49 +00:00
argoyle 7bc3101cee feat: add eventsourced MetricsRecorder adapter for OpenTelemetry (#142)
Release / release (push) Failing after 51s
otelsetup / test (push) Successful in 2m43s
otelsetup / vulnerabilities (push) Successful in 2m44s
pre-commit / pre-commit (push) Successful in 5m55s
2026-05-26 17:57:27 +00:00
releaser a8a8d53cd1 chore(release): prepare for v0.2.7 (#141)
Release / release (push) Successful in 45s
otelsetup / vulnerabilities (push) Successful in 1m44s
otelsetup / test (push) Successful in 2m32s
pre-commit / pre-commit (push) Successful in 6m25s
## [0.2.7] - 2026-05-09

### 🐛 Bug Fixes

- *(deps)* Update opentelemetry-go monorepo (#131)
- *(ci)* Use go-test-coverage binary directly to fix Gitea Actions (#134)
- *(deps)* Update module github.com/99designs/gqlgen to v0.17.90 (#133)

### ⚙️ Miscellaneous Tasks

- *(deps)* Update pre-commit hook alessandrojcm/commitlint-pre-commit-hook to v9.25.0 (#136)
- *(deps)* Update pre-commit hook golangci/golangci-lint to v2.12.0 (#137)
- *(deps)* Update pre-commit hook golangci/golangci-lint to v2.12.1 (#138)
- *(deps)* Update pre-commit hook golangci/golangci-lint to v2.12.2 (#140)

<!-- generated by git-cliff -->

---

**Note:** Please use **Squash Merge** when merging this PR.

Reviewed-on: #141
Co-authored-by: Unbound Releaser <releaser@unbound.se>
Co-committed-by: Unbound Releaser <releaser@unbound.se>
2026-05-09 14:24:35 +00:00
renovate 485b8434d8 chore(deps): update pre-commit hook golangci/golangci-lint to v2.12.2 (#140)
otelsetup / vulnerabilities (push) Successful in 1m46s
otelsetup / test (push) Successful in 2m44s
Release / release (push) Successful in 53s
pre-commit / pre-commit (push) Successful in 6m55s
2026-05-09 12:33:24 +00:00
renovate 9dce38187c chore(deps): update pre-commit hook golangci/golangci-lint to v2.12.1 (#138)
Release / release (push) Successful in 1m12s
otelsetup / vulnerabilities (push) Successful in 1m56s
otelsetup / test (push) Successful in 2m48s
pre-commit / pre-commit (push) Successful in 6m24s
2026-05-04 16:16:10 +00:00
renovate 33f22237dc chore(deps): update pre-commit hook golangci/golangci-lint to v2.12.0 (#137)
otelsetup / vulnerabilities (push) Successful in 1m54s
Release / release (push) Failing after 57s
otelsetup / test (push) Successful in 2m54s
pre-commit / pre-commit (push) Successful in 5m47s
2026-05-04 14:22:17 +00:00
renovate 4bbd8b3e63 chore(deps): update pre-commit hook alessandrojcm/commitlint-pre-commit-hook to v9.25.0 (#136)
Release / release (push) Failing after 49s
otelsetup / vulnerabilities (push) Successful in 1m44s
otelsetup / test (push) Successful in 2m36s
pre-commit / pre-commit (push) Successful in 6m12s
2026-05-03 15:43:30 +00:00
renovate ec0ea2a287 fix(deps): update module github.com/99designs/gqlgen to v0.17.90 (#133)
otelsetup / vulnerabilities (push) Successful in 2m18s
otelsetup / test (push) Successful in 3m18s
Release / release (push) Successful in 1m2s
pre-commit / pre-commit (push) Successful in 6m15s
2026-04-29 06:18:29 +00:00
argoyle dc4bc4a98c fix(ci): use go-test-coverage binary directly to fix Gitea Actions (#134)
Release / release (push) Failing after 1m4s
otelsetup / vulnerabilities (push) Successful in 2m13s
otelsetup / test (push) Successful in 3m21s
pre-commit / pre-commit (push) Successful in 6m28s
## Summary

- `vladopajic/go-test-coverage@v2` (v2.18.5+, released 2026-04-26/27) restructured its composite action to pass inputs via env-var mapping. Gitea `act_runner` doesn't expand `${{ }}` expressions inside docker-action `env:` blocks reliably, so the literal string `${{ inputs.config }}` reached the binary and broke the 'Check coverage' step.
- Replace the action with a direct `go install` + binary invocation (matching the established Frostmoln pattern).
- Use `--github-action-output` to expose `total-coverage` as a step output, replacing the manual `go tool cover -func | grep | awk` calculations.
- Baseline artifact now stores the percentage directly instead of the full coverage profile.

## Test plan

- [x] `prek run --all-files` passes
- [ ] CI passes on this PR
- [ ] After merge, baseline artifact format propagates on next push to main

Reviewed-on: #134
2026-04-29 05:44:15 +00:00
renovate e92f853311 fix(deps): update opentelemetry-go monorepo (#131)
Release / release (push) Failing after 1m4s
otelsetup / vulnerabilities (push) Successful in 1m48s
otelsetup / test (push) Successful in 2m22s
pre-commit / pre-commit (push) Successful in 6m31s
2026-04-06 10:14:40 +00:00
12 changed files with 530 additions and 94 deletions
+13 -17
View File
@@ -10,7 +10,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: 'stable'
@@ -18,11 +18,10 @@ jobs:
run: go test -race -coverprofile=coverage.txt ./...
- name: Check coverage
uses: vladopajic/go-test-coverage@v2
with:
config: ./.testcoverage.yml
# Download baseline coverage from main branch (for PRs)
id: coverage
run: |
go install github.com/vladopajic/go-test-coverage/v2@latest
go-test-coverage --config ./.testcoverage.yml --github-action-output
- name: Download baseline coverage
if: gitea.event_name == 'pull_request'
uses: actions/download-artifact@v3
@@ -30,14 +29,12 @@ jobs:
name: coverage-baseline
path: ./baseline
continue-on-error: true
# Compare coverage against baseline (for PRs)
- name: Compare coverage
if: gitea.event_name == 'pull_request'
run: |
CURRENT=$(go tool cover -func=coverage.txt | grep "^total:" | awk '{print $NF}' | tr -d '%')
CURRENT="${{ steps.coverage.outputs.total-coverage }}"
if [ -f ./baseline/coverage.txt ]; then
BASE=$(go tool cover -func=./baseline/coverage.txt | grep "^total:" | awk '{print $NF}' | tr -d '%')
BASE=$(cat ./baseline/coverage.txt)
echo "Base coverage: ${BASE}%"
echo "Current coverage: ${CURRENT}%"
if [ "$(echo "$CURRENT < $BASE" | bc -l)" -eq 1 ]; then
@@ -49,8 +46,9 @@ jobs:
echo "No baseline coverage found, skipping comparison"
echo "Current coverage: ${CURRENT}%"
fi
# Upload coverage as baseline (only on main)
- name: Save coverage baseline
if: gitea.ref == 'refs/heads/main'
run: echo "${{ steps.coverage.outputs.total-coverage }}" > coverage.txt
- name: Upload coverage baseline
if: gitea.ref == 'refs/heads/main'
uses: actions/upload-artifact@v3
@@ -58,24 +56,22 @@ jobs:
name: coverage-baseline
path: coverage.txt
retention-days: 90
# Post coverage to PR comment
- name: Post coverage comment
if: gitea.event_name == 'pull_request'
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_URL: ${{ gitea.server_url }}
run: |
COVERAGE=$(go tool cover -func=coverage.txt | grep "^total:" | awk '{print $NF}')
COVERAGE="${{ steps.coverage.outputs.total-coverage }}"
curl -X POST "${GITEA_URL}/api/v1/repos/${{ gitea.repository }}/issues/${{ gitea.event.pull_request.number }}/comments" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"body\": \"## Coverage Report\n\nTotal coverage: **${COVERAGE}**\"}"
-d "{\"body\": \"## Coverage Report\n\nTotal coverage: **${COVERAGE}%**\"}"
vulnerabilities:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: 'stable'
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
env:
SKIP: no-commit-to-branch
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: stable
+2 -2
View File
@@ -11,7 +11,7 @@ repos:
- --allow-multiple-documents
- id: check-added-large-files
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
rev: v9.24.0
rev: v9.25.0
hooks:
- id: commitlint
stages: [ commit-msg ]
@@ -30,7 +30,7 @@ repos:
- id: go-test
- id: gofumpt
- repo: https://github.com/golangci/golangci-lint
rev: v2.11.4
rev: v2.12.2
hooks:
- id: golangci-lint-full
- repo: https://github.com/gitleaks/gitleaks
+1 -1
View File
@@ -1,3 +1,3 @@
{
"version": "v0.2.6"
"version": "v0.4.0"
}
+39 -1
View File
@@ -2,7 +2,45 @@
All notable changes to this project will be documented in this file.
## [0.2.6] - 2026-03-24
## [0.4.0] - 2026-06-16
### 🚀 Features
- *(metrics)* Add SubscriptionMetrics OTel observer for subscriptions (#150)
### 🐛 Bug Fixes
- *(deps)* Update opentelemetry-go monorepo (#146)
- *(deps)* Update module github.com/99designs/gqlgen to v0.17.91 (#148)
## [0.3.1] - 2026-05-29
### 🐛 Bug Fixes
- Set service.instance.id for unique instance label (#144)
## [0.3.0] - 2026-05-26
### 🚀 Features
- Add eventsourced MetricsRecorder adapter for OpenTelemetry (#142)
## [0.2.7] - 2026-05-09
### 🐛 Bug Fixes
- *(deps)* Update opentelemetry-go monorepo (#131)
- *(ci)* Use go-test-coverage binary directly to fix Gitea Actions (#134)
- *(deps)* Update module github.com/99designs/gqlgen to v0.17.90 (#133)
### ⚙️ Miscellaneous Tasks
- *(deps)* Update pre-commit hook alessandrojcm/commitlint-pre-commit-hook to v9.25.0 (#136)
- *(deps)* Update pre-commit hook golangci/golangci-lint to v2.12.0 (#137)
- *(deps)* Update pre-commit hook golangci/golangci-lint to v2.12.1 (#138)
- *(deps)* Update pre-commit hook golangci/golangci-lint to v2.12.2 (#140)
## [0.2.6] - 2026-04-02
### 🐛 Bug Fixes
+22 -21
View File
@@ -3,16 +3,18 @@ module gitea.unbound.se/shiny/otelsetup
go 1.25.0
require (
github.com/99designs/gqlgen v0.17.89
go.opentelemetry.io/otel v1.42.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0
go.opentelemetry.io/otel/log v0.18.0
go.opentelemetry.io/otel/sdk v1.42.0
go.opentelemetry.io/otel/sdk/log v0.18.0
go.opentelemetry.io/otel/sdk/metric v1.42.0
go.opentelemetry.io/otel/trace v1.42.0
github.com/99designs/gqlgen v0.17.91
gitlab.com/unboundsoftware/eventsourced/eventsourced v1.23.0
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0
go.opentelemetry.io/otel/log v0.20.0
go.opentelemetry.io/otel/metric v1.44.0
go.opentelemetry.io/otel/sdk v1.44.0
go.opentelemetry.io/otel/sdk/log v0.20.0
go.opentelemetry.io/otel/sdk/metric v1.44.0
go.opentelemetry.io/otel/trace v1.44.0
)
require (
@@ -21,19 +23,18 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/sosodev/duration v1.4.0 // indirect
github.com/vektah/gqlparser/v2 v2.5.32 // indirect
github.com/vektah/gqlparser/v2 v2.5.34 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
golang.org/x/net v0.51.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/grpc v1.79.2 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+50 -50
View File
@@ -1,9 +1,7 @@
github.com/99designs/gqlgen v0.17.89 h1:KzEcxPiMgQoMw3m/E85atUEHyZyt0PbAflMia5Kw8z8=
github.com/99designs/gqlgen v0.17.89/go.mod h1:GFqruTVGB7ZTdrf1uzOagpXbY7DrEt1pIxnTdhIbWvQ=
github.com/99designs/gqlgen v0.17.91 h1:/mIvXnN0lAorqszP3Vukw10SVRfLVUYtBTQFwmYRMmI=
github.com/99designs/gqlgen v0.17.91/go.mod h1:N7+yJF6zbGIEqohF+ZtEUp/eq2dTnn0bDizLUIYPUCU=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -21,64 +19,66 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
github.com/vektah/gqlparser/v2 v2.5.34 h1:MEea5P0qhdcqfBL45ghKE+qr9laidVHTMHjav5h7ckk=
github.com/vektah/gqlparser/v2 v2.5.34/go.mod h1:mFdHLGCio7OGX1fby9ZjTW6FN+qxgmbnBcRIeeScE5s=
gitlab.com/unboundsoftware/eventsourced/eventsourced v1.23.0 h1:qcteJH9D7kHaOgLQ0fzlW9dv42hSa0Vluqt7p4kooWA=
gitlab.com/unboundsoftware/eventsourced/eventsourced v1.23.0/go.mod h1:LrA7I7etRmhIC1PjO8c26BHm+gWsy2rC3eSMe5+XUWE=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws=
go.opentelemetry.io/otel/log v0.18.0 h1:XgeQIIBjZZrliksMEbcwMZefoOSMI1hdjiLEiiB0bAg=
go.opentelemetry.io/otel/log v0.18.0/go.mod h1:KEV1kad0NofR3ycsiDH4Yjcoj0+8206I6Ox2QYFSNgI=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw=
go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk=
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0 h1:l3mYuPsuBx6UKE47BVcPrZoZ0q/KER57vbj2qkgDLXA=
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0/go.mod h1:7cHtiVJpZebB3wybTa4NG+FUo5NPe3PROz1FqB0+qdw=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 h1:aZfdmtI6QU/DAPD4b7YZ5zuJgewxO1EW9miOZklqleU=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0/go.mod h1:isNl10/Om5CBWu9jj8WOb2+tJLbCVXDgqwzCaJMnJ6w=
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg=
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+123
View File
@@ -0,0 +1,123 @@
package otelsetup
import (
"context"
"errors"
"gitlab.com/unboundsoftware/eventsourced/eventsourced"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
// eventsourcedMeterName is the instrumentation scope for the event-sourcing
// metrics emitted by the adapter returned from NewEventsourcedMetrics.
const eventsourcedMeterName = "gitea.unbound.se/shiny/otelsetup/eventsourced"
// durationBucketsSeconds are explicit histogram boundaries tuned for
// sub-second event-store and command latencies. The SDK default boundaries are
// scaled for milliseconds, which would bucket nearly every second-valued
// observation into the first bucket and make percentiles useless.
var durationBucketsSeconds = []float64{0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}
// eventsourcedMetrics implements eventsourced.MetricsRecorder by translating
// the framework's Metric values into OpenTelemetry instruments registered on
// the global MeterProvider configured by SetupOTelSDK.
//
// The OTel metric instruments are safe for concurrent use, and the struct is
// immutable after construction, so Record may be called from multiple
// goroutines as the framework requires.
//
// Operation counts are read off each duration histogram's generated _count
// series rather than separate counters; the only standalone counters carry
// information a histogram count cannot (events.loaded sums the number of events
// per load, idempotency.checks counts lookups that have no duration).
type eventsourcedMetrics struct {
commandDuration metric.Float64Histogram
eventStoreDur metric.Float64Histogram
eventsLoaded metric.Int64Counter
eventLoadDur metric.Float64Histogram
snapshotStoreDur metric.Float64Histogram
snapshotLoadDur metric.Float64Histogram
idempotencyCheck metric.Int64Counter
}
// NewEventsourcedMetrics builds an eventsourced.MetricsRecorder that records to
// the global OpenTelemetry MeterProvider. Pass the result to both
// pg.WithMetrics (for event-store operations) and eventsourced.WithMetrics
// (for command handling) so a single recorder covers store and handler
// metrics.
//
// SetupOTelSDK must have run first so the global MeterProvider is configured;
// when metrics are disabled the global provider is a no-op and recording is
// effectively free.
func NewEventsourcedMetrics() (eventsourced.MetricsRecorder, error) {
m := otel.Meter(eventsourcedMeterName)
var errs []error
hist := func(name, desc string) metric.Float64Histogram {
h, err := m.Float64Histogram(
name,
metric.WithDescription(desc),
metric.WithUnit("s"),
metric.WithExplicitBucketBoundaries(durationBucketsSeconds...),
)
errs = append(errs, err)
return h
}
counter := func(name, desc string) metric.Int64Counter {
c, err := m.Int64Counter(name, metric.WithDescription(desc))
errs = append(errs, err)
return c
}
r := &eventsourcedMetrics{
commandDuration: hist("eventsourced.command.duration", "Wall-clock time to process a command in Handle."),
eventStoreDur: hist("eventsourced.event.store.duration", "Time taken to persist a single event."),
eventsLoaded: counter("eventsourced.events.loaded", "Number of events loaded when rehydrating aggregates."),
eventLoadDur: hist("eventsourced.event.load.duration", "Time taken to load events for an aggregate."),
snapshotStoreDur: hist("eventsourced.snapshot.store.duration", "Time taken to persist a snapshot."),
snapshotLoadDur: hist("eventsourced.snapshot.load.duration", "Time taken to load a snapshot."),
idempotencyCheck: counter("eventsourced.idempotency.checks", "Number of command idempotency lookups."),
}
if err := errors.Join(errs...); err != nil {
return nil, err
}
return r, nil
}
// Record implements eventsourced.MetricsRecorder. Metric types the adapter does
// not recognise (for example pg outbox metrics when the outbox is not enabled)
// are ignored.
func (e *eventsourcedMetrics) Record(ctx context.Context, raw eventsourced.Metric) {
switch m := raw.(type) {
case eventsourced.CommandDuration:
e.commandDuration.Record(ctx, m.Duration.Seconds(), metric.WithAttributes(
attribute.String("command.type", m.CommandType),
attribute.Bool("success", m.Success),
))
case eventsourced.EventStored:
e.eventStoreDur.Record(ctx, m.Duration.Seconds(), metric.WithAttributes(
attribute.String("aggregate.type", m.AggregateType),
attribute.String("event.type", m.EventType),
))
case eventsourced.EventsLoaded:
attrs := metric.WithAttributes(attribute.String("aggregate.type", m.AggregateType))
e.eventsLoaded.Add(ctx, int64(m.EventCount), attrs)
e.eventLoadDur.Record(ctx, m.Duration.Seconds(), attrs)
case eventsourced.SnapshotStored:
e.snapshotStoreDur.Record(ctx, m.Duration.Seconds(), metric.WithAttributes(
attribute.String("aggregate.type", m.AggregateType),
attribute.Bool("success", m.Success),
))
case eventsourced.SnapshotLoaded:
e.snapshotLoadDur.Record(ctx, m.Duration.Seconds(), metric.WithAttributes(
attribute.String("aggregate.type", m.AggregateType),
attribute.Bool("found", m.Found),
))
case eventsourced.IdempotencyCheck:
e.idempotencyCheck.Add(ctx, 1, metric.WithAttributes(
attribute.String("aggregate.type", m.AggregateType),
attribute.Bool("hit", m.Hit),
))
}
}
+75
View File
@@ -0,0 +1,75 @@
package otelsetup
import (
"context"
"sort"
"testing"
"time"
"gitlab.com/unboundsoftware/eventsourced/eventsourced"
"go.opentelemetry.io/otel"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
)
func TestNewEventsourcedMetrics_RecordsContract(t *testing.T) {
reader := sdkmetric.NewManualReader()
otel.SetMeterProvider(sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)))
r, err := NewEventsourcedMetrics()
if err != nil {
t.Fatalf("NewEventsourcedMetrics returned error: %v", err)
}
if r == nil {
t.Fatal("NewEventsourcedMetrics returned nil recorder")
}
// Recording every known metric type (and an unknown one) must not panic
// and must emit the expected instruments.
for _, m := range []eventsourced.Metric{
eventsourced.CommandDuration{CommandType: "AddEntry", Duration: time.Millisecond, Success: true},
eventsourced.EventStored{AggregateType: "Entry", EventType: "EntryAdded", Duration: time.Millisecond},
eventsourced.EventsLoaded{AggregateType: "Entry", EventCount: 3, Duration: time.Millisecond},
eventsourced.SnapshotStored{AggregateType: "Entry", Duration: time.Millisecond, Success: true},
eventsourced.SnapshotLoaded{AggregateType: "Entry", Found: false, Duration: time.Millisecond},
eventsourced.IdempotencyCheck{AggregateType: "Entry", Hit: true},
unknownMetric{},
} {
r.Record(context.Background(), m)
}
var rm metricdata.ResourceMetrics
if err := reader.Collect(context.Background(), &rm); err != nil {
t.Fatalf("collect: %v", err)
}
got := map[string]bool{}
for _, sm := range rm.ScopeMetrics {
for _, md := range sm.Metrics {
got[md.Name] = true
}
}
want := []string{
"eventsourced.command.duration",
"eventsourced.event.store.duration",
"eventsourced.events.loaded",
"eventsourced.event.load.duration",
"eventsourced.snapshot.store.duration",
"eventsourced.snapshot.load.duration",
"eventsourced.idempotency.checks",
}
var missing []string
for _, w := range want {
if !got[w] {
missing = append(missing, w)
}
}
if len(missing) > 0 {
sort.Strings(missing)
t.Errorf("missing expected metrics: %v", missing)
}
}
type unknownMetric struct{}
func (unknownMetric) IsMetric() {}
+11 -1
View File
@@ -22,7 +22,17 @@ import (
// SetupOTelSDK bootstraps the OpenTelemetry pipeline.
func SetupOTelSDK(ctx context.Context, enabled bool, serviceName, buildVersion, environment string) (func(context.Context) error, error) {
if os.Getenv("OTEL_RESOURCE_ATTRIBUTES") == "" {
if err := os.Setenv("OTEL_RESOURCE_ATTRIBUTES", fmt.Sprintf("service.name=%s,service.version=%s,service.environment=%s", serviceName, buildVersion, environment)); err != nil {
// service.instance.id makes every pod a distinct telemetry resource. The
// OTLP→Prometheus exporter maps it to the `instance` label on metrics and
// target_info, which keeps multi-replica services from colliding on a
// single series and gives joins a unique (job, instance) key. Hostname is
// the pod name under Kubernetes; fall back to the service name if it is
// unavailable so the attribute is always present.
instanceID, err := os.Hostname()
if err != nil || instanceID == "" {
instanceID = serviceName
}
if err := os.Setenv("OTEL_RESOURCE_ATTRIBUTES", fmt.Sprintf("service.name=%s,service.version=%s,service.environment=%s,service.instance.id=%s", serviceName, buildVersion, environment, instanceID)); err != nil {
return func(context.Context) error {
return nil
}, err
+85
View File
@@ -0,0 +1,85 @@
package otelsetup
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
// subscriptionMeterName is the instrumentation scope for the cross-service
// subscription push metrics emitted by SubscriptionMetrics.
const subscriptionMeterName = "gitea.unbound.se/shiny/otelsetup/subscriptions"
// SubscriptionMetrics records cross-service subscription push outcomes (the
// read-your-writes subscriptions described in ADR-0012) as OpenTelemetry
// counters.
//
// Its method set (Pushed/PushSkipped/Dropped/ChannelFull) satisfies the
// Observer interface of gitea.unbound.se/shiny/subscriptions *structurally* —
// otelsetup does not import that library. A service constructs it and passes it
// to the registry:
//
// metrics, err := otelsetup.NewSubscriptionMetrics("availableCompanies")
// if err != nil { return err }
// reg := subscriptions.New[T](subscriptions.WithObserver(metrics))
//
// The WithObserver call type-checks the structural match, so a drift in the
// Observer interface fails the service build. Add a
// `var _ subscriptions.Observer = (*otelsetup.SubscriptionMetrics)(nil)` next to
// it for an explicit guard.
//
// Outcomes are recorded against a low-cardinality (subscription, outcome) pair.
// The subscriber key (company id / user email) the Observer methods receive is
// deliberately NOT used as a metric attribute — that would be unbounded
// cardinality; the key stays in the subscriptions library's logs for
// correlation.
type SubscriptionMetrics struct {
notifications metric.Int64Counter
subscription attribute.KeyValue
}
// NewSubscriptionMetrics builds a SubscriptionMetrics that records to the global
// OpenTelemetry MeterProvider. subscription is the low-cardinality name of the
// subscription field (e.g. "availableCompanies", "entryBasesChanged"), recorded
// as an attribute so one counter covers every subscription.
//
// SetupOTelSDK must have run first so the global MeterProvider is configured;
// when metrics are disabled the global provider is a no-op and recording is
// effectively free.
func NewSubscriptionMetrics(subscription string) (*SubscriptionMetrics, error) {
c, err := otel.Meter(subscriptionMeterName).Int64Counter(
"subscription.notifications",
metric.WithDescription("Cross-service subscription push outcomes, by outcome (pushed/skipped/dropped/channel_full)."),
)
if err != nil {
return nil, err
}
return &SubscriptionMetrics{
notifications: c,
subscription: attribute.String("subscription", subscription),
}, nil
}
func (s *SubscriptionMetrics) record(outcome string) {
s.notifications.Add(context.Background(), 1, metric.WithAttributes(
s.subscription,
attribute.String("outcome", outcome),
))
}
// Pushed records a change that was gated and delivered to the key's subscribers
// — the denominator for a skip/drop rate.
func (s *SubscriptionMetrics) Pushed(string) { s.record("pushed") }
// PushSkipped records a push skipped because the read view never reflected the
// change within the retry budget.
func (s *SubscriptionMetrics) PushSkipped(string) { s.record("skipped") }
// Dropped records a notification dropped because the worker queue was full.
func (s *SubscriptionMetrics) Dropped(string) { s.record("dropped") }
// ChannelFull records a notification dropped because a subscriber's buffer was
// full.
func (s *SubscriptionMetrics) ChannelFull(string) { s.record("channel_full") }
+108
View File
@@ -0,0 +1,108 @@
package otelsetup
import (
"context"
"testing"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric/noop"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
)
// observerShape mirrors gitea.unbound.se/shiny/subscriptions.Observer. This
// compile-time assertion guards that SubscriptionMetrics still satisfies that
// interface structurally, without otelsetup importing the library.
//
// KEEP IN SYNC with subscriptions.Observer: this only proves SubscriptionMetrics
// matches this local copy. The authoritative check that the local copy still
// matches the real interface is the `subscriptions.WithObserver(...)` call site
// in each consuming service — keep a `var _ subscriptions.Observer` guard there.
type observerShape interface {
Pushed(string)
PushSkipped(string)
Dropped(string)
ChannelFull(string)
}
var _ observerShape = (*SubscriptionMetrics)(nil)
// TestSubscriptionMetrics_DisabledProviderIsSafe proves the "recording is free
// when metrics are disabled" claim: with the default global no-op provider, the
// methods neither panic nor emit instruments.
func TestSubscriptionMetrics_DisabledProviderIsSafe(t *testing.T) {
otel.SetMeterProvider(noop.NewMeterProvider())
m, err := NewSubscriptionMetrics("entryBasesChanged")
if err != nil {
t.Fatalf("NewSubscriptionMetrics returned error: %v", err)
}
// Must not panic on the no-op provider.
m.Pushed("c1")
m.PushSkipped("c1")
m.Dropped("c1")
m.ChannelFull("c1")
}
func TestNewSubscriptionMetrics_RecordsOutcomes(t *testing.T) {
reader := sdkmetric.NewManualReader()
otel.SetMeterProvider(sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)))
m, err := NewSubscriptionMetrics("availableCompanies")
if err != nil {
t.Fatalf("NewSubscriptionMetrics returned error: %v", err)
}
// The subscriber key is ignored for metrics; different keys must not create
// new series (cardinality guard is implicit — we only label by outcome).
m.Pushed("c1")
m.Pushed("c2")
m.PushSkipped("c1")
m.Dropped("c1")
m.ChannelFull("c1")
var rm metricdata.ResourceMetrics
if err := reader.Collect(context.Background(), &rm); err != nil {
t.Fatalf("collect: %v", err)
}
counts := map[string]int64{}
dataPoints := 0
found := false
for _, sm := range rm.ScopeMetrics {
for _, md := range sm.Metrics {
if md.Name != "subscription.notifications" {
continue
}
found = true
sum, ok := md.Data.(metricdata.Sum[int64])
if !ok {
t.Fatalf("expected Sum[int64], got %T", md.Data)
}
for _, dp := range sum.DataPoints {
dataPoints++
sub, _ := dp.Attributes.Value(attribute.Key("subscription"))
if sub.AsString() != "availableCompanies" {
t.Errorf("unexpected subscription attribute: %q", sub.AsString())
}
outcome, _ := dp.Attributes.Value(attribute.Key("outcome"))
counts[outcome.AsString()] += dp.Value
}
}
}
if !found {
t.Fatal("subscription.notifications counter not emitted")
}
// One series per outcome, keyed by outcome only (not by subscriber key).
if dataPoints != 4 {
t.Errorf("expected 4 data points (one per outcome), got %d", dataPoints)
}
want := map[string]int64{"pushed": 2, "skipped": 1, "dropped": 1, "channel_full": 1}
for k, v := range want {
if counts[k] != v {
t.Errorf("outcome %q: got %d, want %d", k, counts[k], v)
}
}
}