Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/adapters.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Adapters

# The adapter modules live in their own go.mod files so the core module graph
# stays stdlib-only. They are not covered by the root CI's ./... , so build and
# test each one independently here.

on:
push:
branches: [main]
paths:
- 'adapters/**'
- 'runtime/**'
- 'store/**'
- 'go.mod'
- '.github/workflows/adapters.yml'
pull_request:
branches: [main]
paths:
- 'adapters/**'
- 'runtime/**'
- 'store/**'
- 'go.mod'
- '.github/workflows/adapters.yml'

permissions:
contents: read

jobs:
test:
name: ${{ matrix.module }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
module: [adapters/otel, adapters/sql]
defaults:
run:
working-directory: ${{ matrix.module }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26'
check-latest: true
- name: Verify formatting
run: |
fmt_out="$(gofmt -l .)"
if [ -n "$fmt_out" ]; then
echo "Not gofmt-clean:"; echo "$fmt_out"; exit 1
fi
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
- name: Test (race)
run: go test -race ./...
37 changes: 37 additions & 0 deletions adapters/otel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Isopace OpenTelemetry adapter

An implementation of the Isopace `runtime.Observer` traces-and-metrics facade
over [OpenTelemetry](https://opentelemetry.io).

This is a **separate module** so the Isopace core never imports a telemetry SDK
(the core defines a tiny, dependency-free `Observer` interface; this adapter
bridges it to OTel). Wire it in at the edge of your application:

```go
import (
oteladapter "github.com/teqpace-services/isopace/adapters/otel"
"github.com/teqpace-services/isopace/runtime"
)

// From explicit providers:
obs := oteladapter.New(tracerProvider, meterProvider)

// …or from the globally-registered OpenTelemetry providers:
obs := oteladapter.Default()

host := runtime.NewHost(runtime.WithObserver(obs))
```

## Mapping

| Isopace | OpenTelemetry |
|---|---|
| `Observer.StartSpan` | `Tracer.Start` (attributes attached at start) |
| `Span.End` / `SetError` / `SetAttr` | `Span.End` / `RecordError`+`SetStatus(Error)` / `SetAttributes` |
| `Observer.Counter(name).Add` | `Int64Counter.Add` |
| `Observer.Histogram(name).Observe` | `Float64Histogram.Record` |
| `runtime.Attr` | `attribute.KeyValue` (string/bool/int/int64/float64; else stringified) |

Note: the `Counter`/`Histogram` contract carries no `context.Context`, so the
background context is used for measurements — OTel metric aggregation does not
depend on request context.
25 changes: 25 additions & 0 deletions adapters/otel/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Isopace OpenTelemetry observer adapter — a separate module so the stdlib-only
// core never imports a telemetry SDK. require/replace mirror the other adapters;
// the OpenTelemetry requirements are filled in by `go mod tidy`.
module github.com/teqpace-services/isopace/adapters/otel

go 1.26

require (
github.com/teqpace-services/isopace v0.3.0
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/metric v1.44.0
go.opentelemetry.io/otel/sdk v1.44.0
go.opentelemetry.io/otel/trace v1.44.0
)

require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
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
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
golang.org/x/sys v0.45.0 // indirect
)

replace github.com/teqpace-services/isopace => ../..
35 changes: 35 additions & 0 deletions adapters/otel/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
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/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/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.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
146 changes: 146 additions & 0 deletions adapters/otel/otel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// Copyright (C) 2026 Teqpace Services Ltd.
//
// This file is part of Isopace, a financial transaction framework.
//
// Isopace is dual-licensed:
// - under the GNU Affero General Public License v3.0 or later (see LICENSE); or
// - under a commercial license from Teqpace Services Ltd. (see COMMERCIAL-LICENSE.md).
//
// Authorship is recorded in the AUTHORS file.

// Package otel implements the Isopace runtime.Observer traces-and-metrics facade
// over OpenTelemetry. It lives in a separate module so the Isopace core never
// imports a telemetry SDK; wire it in at the edge:
//
// host := runtime.NewHost(runtime.WithObserver(otel.Default()))
package otel

import (
"context"
"fmt"

"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"

"github.com/teqpace-services/isopace/runtime"
)

// scopeName is the instrumentation scope reported to OpenTelemetry.
const scopeName = "github.com/teqpace-services/isopace"

// Observer implements runtime.Observer over OpenTelemetry providers.
type Observer struct {
tracer trace.Tracer
meter metric.Meter
}

var _ runtime.Observer = (*Observer)(nil)

// New builds an Observer from explicit OpenTelemetry providers.
func New(tp trace.TracerProvider, mp metric.MeterProvider) *Observer {
return &Observer{tracer: tp.Tracer(scopeName), meter: mp.Meter(scopeName)}
}

// Default builds an Observer from the globally-registered OpenTelemetry
// providers (otel.GetTracerProvider / otel.GetMeterProvider).
func Default() *Observer {
return New(otel.GetTracerProvider(), otel.GetMeterProvider())
}

// StartSpan begins a span; the returned context carries it for propagation.
func (o *Observer) StartSpan(ctx context.Context, name string, attrs ...runtime.Attr) (context.Context, runtime.Span) {
ctx, sp := o.tracer.Start(ctx, name, trace.WithAttributes(kvs(attrs)...))
return ctx, &span{sp: sp}
}

// Counter returns a monotonic Int64 counter by name.
func (o *Observer) Counter(name string) runtime.Counter {
c, err := o.meter.Int64Counter(name)
if err != nil {
return noopCounter{}
}
return &counter{c: c}
}

// Histogram returns a Float64 distribution instrument by name.
func (o *Observer) Histogram(name string) runtime.Histogram {
h, err := o.meter.Float64Histogram(name)
if err != nil {
return noopHistogram{}
}
return &histogram{h: h}
}

type span struct{ sp trace.Span }

func (s *span) End() { s.sp.End() }

func (s *span) SetError(err error) {
if err == nil {
return
}
s.sp.RecordError(err)
s.sp.SetStatus(codes.Error, err.Error())
}

func (s *span) SetAttr(attrs ...runtime.Attr) { s.sp.SetAttributes(kvs(attrs)...) }

type counter struct{ c metric.Int64Counter }

// Add records an increment. The runtime.Counter contract carries no context, so
// the background context is used; OpenTelemetry metric instruments do not depend
// on request context for aggregation.
func (c *counter) Add(n int64, attrs ...runtime.Attr) {
c.c.Add(context.Background(), n, metric.WithAttributes(kvs(attrs)...))
}

type histogram struct{ h metric.Float64Histogram }

func (h *histogram) Observe(v float64, attrs ...runtime.Attr) {
h.h.Record(context.Background(), v, metric.WithAttributes(kvs(attrs)...))
}

// noop instruments returned when the meter fails to construct one, so callers
// never need a nil check.
type noopCounter struct{}

func (noopCounter) Add(int64, ...runtime.Attr) {}

type noopHistogram struct{}

func (noopHistogram) Observe(float64, ...runtime.Attr) {}

// kvs converts Isopace attributes to OpenTelemetry key/values.
func kvs(attrs []runtime.Attr) []attribute.KeyValue {
if len(attrs) == 0 {
return nil
}
out := make([]attribute.KeyValue, 0, len(attrs))
for _, a := range attrs {
out = append(out, kv(a))
}
return out
}

func kv(a runtime.Attr) attribute.KeyValue {
k := attribute.Key(a.Key)
switch v := a.Value.(type) {
case string:
return k.String(v)
case bool:
return k.Bool(v)
case int:
return k.Int(v)
case int64:
return k.Int64(v)
case float64:
return k.Float64(v)
default:
return k.String(fmt.Sprintf("%v", v))
}
}
76 changes: 76 additions & 0 deletions adapters/otel/otel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// Copyright (C) 2026 Teqpace Services Ltd.
//
// This file is part of Isopace, a financial transaction framework.
//
// Isopace is dual-licensed:
// - under the GNU Affero General Public License v3.0 or later (see LICENSE); or
// - under a commercial license from Teqpace Services Ltd. (see COMMERCIAL-LICENSE.md).
//
// Authorship is recorded in the AUTHORS file.

package otel_test

import (
"context"
"errors"
"testing"

"go.opentelemetry.io/otel/codes"
noopmetric "go.opentelemetry.io/otel/metric/noop"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
nooptrace "go.opentelemetry.io/otel/trace/noop"

oteladapter "github.com/teqpace-services/isopace/adapters/otel"
"github.com/teqpace-services/isopace/runtime"
)

var _ runtime.Observer = (*oteladapter.Observer)(nil)

func TestStartSpanRecordsNameAttributesAndError(t *testing.T) {
sr := tracetest.NewSpanRecorder()
tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr))
obs := oteladapter.New(tp, noopmetric.NewMeterProvider())

_, span := obs.StartSpan(context.Background(), "auth", runtime.A("mti", "0200"))
span.SetAttr(runtime.A("rc", "00"))
span.SetError(errors.New("declined"))
span.End()

spans := sr.Ended()
if len(spans) != 1 {
t.Fatalf("recorded %d spans, want 1", len(spans))
}
s := spans[0]
if s.Name() != "auth" {
t.Errorf("span name = %q want %q", s.Name(), "auth")
}
got := map[string]string{}
for _, a := range s.Attributes() {
got[string(a.Key)] = a.Value.AsString()
}
for k, want := range map[string]string{"mti": "0200", "rc": "00"} {
if got[k] != want {
t.Errorf("attribute %q = %q want %q (all=%v)", k, got[k], want, got)
}
}
if s.Status().Code != codes.Error {
t.Errorf("span status = %v want Error", s.Status().Code)
}
}

func TestCounterAndHistogramDoNotPanic(t *testing.T) {
obs := oteladapter.New(nooptrace.NewTracerProvider(), noopmetric.NewMeterProvider())
obs.Counter("isopace_test_total").Add(1, runtime.A("k", "v"))
obs.Histogram("isopace_test_latency_ms").Observe(1.5, runtime.A("k", "v"))
_, sp := obs.StartSpan(context.Background(), "noop")
sp.End()
}

func TestDefaultUsesGlobalProviders(t *testing.T) {
if oteladapter.Default() == nil {
t.Fatal("Default() returned nil")
}
}
Loading
Loading