Compare commits
6 Commits
| Author | SHA256 | Date | |
|---|---|---|---|
| d5c9b338a4 | |||
| b14c5e2696 | |||
| 3f5fd860bf | |||
| 9b6c438cc7 | |||
| dedb9281eb | |||
| 83720ba9d1 |
@@ -28,6 +28,9 @@ go.work.sum
|
|||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# macOS metadata
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
# ---> JupyterNotebooks
|
# ---> JupyterNotebooks
|
||||||
# gitignore template for Jupyter Notebooks
|
# gitignore template for Jupyter Notebooks
|
||||||
# website: http://jupyter.org/
|
# website: http://jupyter.org/
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
# General Ledger service design
|
# General Ledger service design
|
||||||
|
|
||||||
Status: accepted for the first implementation slice on 2026-08-14.
|
Status: append-only model accepted on 2026-08-14; authority and availability policy revised on 2026-08-28.
|
||||||
|
|
||||||
## Purpose and ownership
|
## Purpose and ownership
|
||||||
|
|
||||||
`GL` is Darano's durable financial journal. It records every committed wallet
|
`GL` is Darano's primary financial source of truth. It records every wallet
|
||||||
value movement independently of Stellar so balances and transaction history can
|
value movement as an append-only journal. Kuknos, through its Stellar-compatible
|
||||||
be reconstructed during a blockchain or provider outage.
|
interface, is the secondary settlement and verification source of truth.
|
||||||
|
|
||||||
GL owns its PostgreSQL database and exposes an internal gRPC API. Wallet does
|
GL owns its PostgreSQL database and exposes an internal gRPC API. Wallet does
|
||||||
not write GL tables directly, and GL does not write wallet tables. In normal
|
not write GL tables directly, and GL does not write wallet tables. In normal
|
||||||
operation Wallet remains the transaction orchestrator and Stellar remains the
|
operation a transaction requires both GL and Kuknos. If GL is unhealthy, all
|
||||||
external settlement network. Promoting GL from a mirror to an operational
|
value-changing transaction admission and processing halt until it recovers. If
|
||||||
fallback is an explicit, audited mode change; an outage must never make a failed
|
Kuknos is unavailable, an authorized operator may explicitly enable GL-only
|
||||||
blockchain operation appear successful automatically.
|
operation through an AdminPanel toggle or configuration. Kuknos must never be
|
||||||
|
disabled automatically.
|
||||||
|
|
||||||
## Non-negotiable invariants
|
## Non-negotiable invariants
|
||||||
|
|
||||||
@@ -114,13 +115,20 @@ does not hold a wallet database transaction open.
|
|||||||
|
|
||||||
## Operating modes and failure semantics
|
## Operating modes and failure semantics
|
||||||
|
|
||||||
- `MIRROR`: normal mode. Wallet follows its existing settlement policy and GL
|
- `NORMAL`: GL and Kuknos must both be healthy. A transaction is successful
|
||||||
asynchronously records every committed effect.
|
only after its required GL journal and Kuknos settlement evidence exist.
|
||||||
- `DEGRADED_LEDGER`: explicitly enabled by an authorized operator. Eligible
|
- `KUKNOS_DISABLED`: explicitly enabled and disabled by an authorized operator
|
||||||
internal operations may settle against GL while blockchain-bound operations
|
through AdminPanel or configuration. GL remains mandatory and authoritative;
|
||||||
remain pending. The initial implementation does not activate this mode.
|
eligible transactions may proceed without Kuknos. Every mode transition must
|
||||||
- `RECONCILE`: outbound posting is paused or restricted while tooling compares
|
be immutable, attributable, time-bounded where configured, and emitted via
|
||||||
Wallet, GL, and blockchain state and appends approved reversals/corrections.
|
OpenTelemetry. This mode is not implemented yet.
|
||||||
|
- `RECONCILE`: transaction processing is paused or restricted while tooling
|
||||||
|
compares GL and Kuknos and appends approved reversals/corrections. Existing
|
||||||
|
ledger records are never edited or deleted.
|
||||||
|
|
||||||
|
GL failure is always fail-closed. Public readiness reports a critical state and
|
||||||
|
the incident and recovery are emitted through OpenTelemetry. Kuknos failure is
|
||||||
|
also fail-closed unless `KUKNOS_DISABLED` has been explicitly authorized.
|
||||||
|
|
||||||
GL rejects unbalanced journals, invalid precision, unknown account/asset
|
GL rejects unbalanced journals, invalid precision, unknown account/asset
|
||||||
combinations, duplicate line numbers, missing source identity, conflicting
|
combinations, duplicate line numbers, missing source identity, conflicting
|
||||||
@@ -155,7 +163,7 @@ are adapter concerns.
|
|||||||
|
|
||||||
## Initial non-goals
|
## Initial non-goals
|
||||||
|
|
||||||
- Replacing Stellar automatically on health-check failure.
|
- Disabling Kuknos automatically on health-check failure.
|
||||||
- Editing or deleting posted journals.
|
- Editing or deleting posted journals.
|
||||||
- Storing binary floats or using Wallet's mutable transaction table as GL.
|
- Storing binary floats or using Wallet's mutable transaction table as GL.
|
||||||
- Sharing a database schema between Wallet and GL.
|
- Sharing a database schema between Wallet and GL.
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ func NewService(database Database) *Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Check(ctx context.Context) Status {
|
func (s *Service) Check(ctx context.Context) Status {
|
||||||
status := Status{Serving: true}
|
status := Status{}
|
||||||
if s.database != nil {
|
if s.database != nil {
|
||||||
status.DatabaseReady = s.database.Ping(ctx) == nil
|
status.DatabaseReady = s.database.Ping(ctx) == nil
|
||||||
}
|
}
|
||||||
|
status.Serving = status.DatabaseReady
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func TestCheckReportsDatabaseReadiness(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
got := NewService(tc.db).Check(context.Background())
|
got := NewService(tc.db).Check(context.Background())
|
||||||
if !got.Serving || got.DatabaseReady != tc.ready {
|
if got.Serving != tc.ready || got.DatabaseReady != tc.ready {
|
||||||
t.Fatalf("unexpected status: %+v", got)
|
t.Fatalf("unexpected status: %+v", got)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package reconciliation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gl/domain/ledger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Evidence is a normalized record supplied by Wallet, Kuknos, or another
|
||||||
|
// authoritative settlement source. Adapters remain outside the GL domain.
|
||||||
|
type Evidence struct {
|
||||||
|
SourceService string
|
||||||
|
SourceTransactionID string
|
||||||
|
EventVersion uint32
|
||||||
|
BlockchainNetwork string
|
||||||
|
TransactionHash string
|
||||||
|
}
|
||||||
|
|
||||||
|
type EvidenceReport struct {
|
||||||
|
Compared int
|
||||||
|
Missing []Evidence
|
||||||
|
Duplicates []Evidence
|
||||||
|
Mismatched []EvidenceMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
type EvidenceMismatch struct {
|
||||||
|
Evidence Evidence
|
||||||
|
JournalID string
|
||||||
|
Field string
|
||||||
|
Expected string
|
||||||
|
Actual string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareEvidence compares normalized external evidence with immutable GL
|
||||||
|
// journals. It is read-only and safe to run repeatedly after outages.
|
||||||
|
func CompareEvidence(ctx context.Context, repository Repository, evidence []Evidence, pageSize int) (EvidenceReport, error) {
|
||||||
|
if repository == nil {
|
||||||
|
return EvidenceReport{}, fmt.Errorf("reconciliation repository is required")
|
||||||
|
}
|
||||||
|
if pageSize <= 0 || pageSize > 200 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
journals := make(map[string]ledger.Journal)
|
||||||
|
for offset := 0; ; offset += pageSize {
|
||||||
|
page, err := repository.List(ctx, ledger.JournalFilter{Limit: pageSize, Offset: offset})
|
||||||
|
if err != nil {
|
||||||
|
return EvidenceReport{}, fmt.Errorf("list journals at offset %d: %w", offset, err)
|
||||||
|
}
|
||||||
|
for _, journal := range page {
|
||||||
|
journals[evidenceKey(journal.SourceService, journal.SourceTransactionID, journal.EventVersion)] = journal
|
||||||
|
}
|
||||||
|
if len(page) < pageSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
report := EvidenceReport{Missing: make([]Evidence, 0), Duplicates: make([]Evidence, 0), Mismatched: make([]EvidenceMismatch, 0)}
|
||||||
|
seen := make(map[string]struct{}, len(evidence))
|
||||||
|
for _, item := range evidence {
|
||||||
|
report.Compared++
|
||||||
|
key := evidenceKey(item.SourceService, item.SourceTransactionID, item.EventVersion)
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
report.Duplicates = append(report.Duplicates, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
journal, exists := journals[key]
|
||||||
|
if !exists {
|
||||||
|
report.Missing = append(report.Missing, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if item.BlockchainNetwork != "" && item.BlockchainNetwork != journal.Blockchain.Network {
|
||||||
|
report.Mismatched = append(report.Mismatched, EvidenceMismatch{Evidence: item, JournalID: journal.ID, Field: "blockchain_network", Expected: item.BlockchainNetwork, Actual: journal.Blockchain.Network})
|
||||||
|
}
|
||||||
|
if item.TransactionHash != "" && item.TransactionHash != journal.Blockchain.TransactionHash {
|
||||||
|
report.Mismatched = append(report.Mismatched, EvidenceMismatch{Evidence: item, JournalID: journal.ID, Field: "transaction_hash", Expected: item.TransactionHash, Actual: journal.Blockchain.TransactionHash})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return report, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func evidenceKey(sourceService, sourceTransactionID string, eventVersion uint32) string {
|
||||||
|
return sourceService + "\x00" + sourceTransactionID + "\x00" + fmt.Sprint(eventVersion)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package reconciliation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gl/domain/ledger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompareEvidenceReportsMissingDuplicateAndBlockchainMismatch(t *testing.T) {
|
||||||
|
journal := validJournal()
|
||||||
|
journal.Blockchain = ledger.BlockchainReference{Network: "kuknos", TransactionHash: "hash-1"}
|
||||||
|
matching := Evidence{SourceService: "wallet", SourceTransactionID: "t1", EventVersion: 1, BlockchainNetwork: "kuknos", TransactionHash: "hash-1"}
|
||||||
|
evidence := []Evidence{
|
||||||
|
matching,
|
||||||
|
matching,
|
||||||
|
{SourceService: "wallet", SourceTransactionID: "missing", EventVersion: 1},
|
||||||
|
{SourceService: "wallet", SourceTransactionID: "t1", EventVersion: 1, BlockchainNetwork: "kuknos", TransactionHash: "wrong"},
|
||||||
|
}
|
||||||
|
report, err := CompareEvidence(context.Background(), repositoryStub{pages: [][]ledger.Journal{{journal}}}, evidence, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if report.Compared != 4 || len(report.Missing) != 1 || len(report.Duplicates) != 2 || len(report.Mismatched) != 0 {
|
||||||
|
t.Fatalf("unexpected report: %+v", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareEvidenceReportsHashMismatch(t *testing.T) {
|
||||||
|
journal := validJournal()
|
||||||
|
journal.Blockchain.TransactionHash = "actual"
|
||||||
|
report, err := CompareEvidence(context.Background(), repositoryStub{pages: [][]ledger.Journal{{journal}}}, []Evidence{{SourceService: "wallet", SourceTransactionID: "t1", EventVersion: 1, TransactionHash: "expected"}}, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(report.Mismatched) != 1 || report.Mismatched[0].Field != "transaction_hash" {
|
||||||
|
t.Fatalf("unexpected report: %+v", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Package reconciliation provides read-only integrity checks for the GL.
|
||||||
|
package reconciliation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gl/domain/ledger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
List(context.Context, ledger.JournalFilter) ([]ledger.Journal, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Report struct {
|
||||||
|
Scanned int
|
||||||
|
Valid int
|
||||||
|
Invalid []Issue
|
||||||
|
}
|
||||||
|
|
||||||
|
type Issue struct {
|
||||||
|
JournalID string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run scans all posted journals without mutating the ledger.
|
||||||
|
func Run(ctx context.Context, repository Repository, pageSize int) (Report, error) {
|
||||||
|
if repository == nil {
|
||||||
|
return Report{}, fmt.Errorf("reconciliation repository is required")
|
||||||
|
}
|
||||||
|
if pageSize <= 0 || pageSize > 200 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
report := Report{Invalid: make([]Issue, 0)}
|
||||||
|
seen := make(map[string]string)
|
||||||
|
for offset := 0; ; offset += pageSize {
|
||||||
|
journals, err := repository.List(ctx, ledger.JournalFilter{Limit: pageSize, Offset: offset})
|
||||||
|
if err != nil {
|
||||||
|
return Report{}, fmt.Errorf("list journals at offset %d: %w", offset, err)
|
||||||
|
}
|
||||||
|
for _, journal := range journals {
|
||||||
|
report.Scanned++
|
||||||
|
if err := journal.Validate(); err != nil {
|
||||||
|
report.Invalid = append(report.Invalid, Issue{JournalID: journal.ID, Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := evidenceKey(journal.SourceService, journal.SourceTransactionID, journal.EventVersion)
|
||||||
|
if previous, exists := seen[key]; exists {
|
||||||
|
report.Invalid = append(report.Invalid, Issue{JournalID: journal.ID, Error: fmt.Sprintf("duplicate source transaction/version; first journal %s", previous)})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = journal.ID
|
||||||
|
report.Valid++
|
||||||
|
}
|
||||||
|
if len(journals) < pageSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return report, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package reconciliation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gl/domain/ledger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type repositoryStub struct {
|
||||||
|
pages [][]ledger.Journal
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r repositoryStub) List(_ context.Context, filter ledger.JournalFilter) ([]ledger.Journal, error) {
|
||||||
|
index := filter.Offset / filter.Limit
|
||||||
|
if index >= len(r.pages) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return r.pages[index], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validJournal() ledger.Journal {
|
||||||
|
return ledger.Journal{ID: "j1", SourceService: "wallet", IdempotencyKey: "k1", SourceTransactionID: "t1", EffectKind: "transfer", EventVersion: 1, OccurredAt: ledgerTestTime(), PayloadHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Entries: []ledger.Entry{{LineNumber: 1, Account: ledger.AccountReference{Class: ledger.AccountClassTreasury, AssetID: 1}, Amount: mustAmount("-1")}, {LineNumber: 2, Account: ledger.AccountReference{Class: ledger.AccountClassTreasury, AssetID: 1}, Amount: mustAmount("1")}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ledgerTestTime() (t time.Time) { return time.Unix(1, 0).UTC() }
|
||||||
|
func mustAmount(value string) ledger.Amount { amount, _ := ledger.ParseAmount(value); return amount }
|
||||||
|
|
||||||
|
func TestRunScansAndReportsInvalidJournals(t *testing.T) {
|
||||||
|
valid := validJournal()
|
||||||
|
invalid := valid
|
||||||
|
invalid.Entries = append([]ledger.Entry(nil), valid.Entries...)
|
||||||
|
invalid.ID = "bad"
|
||||||
|
invalid.Entries[1].Amount = mustAmount("2")
|
||||||
|
report, err := Run(context.Background(), repositoryStub{pages: [][]ledger.Journal{{valid}, {invalid}}}, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if report.Scanned != 2 || report.Valid != 1 || len(report.Invalid) != 1 || report.Invalid[0].JournalID != "bad" {
|
||||||
|
t.Fatalf("unexpected report: %+v", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunReportsDuplicateTransactionVersions(t *testing.T) {
|
||||||
|
first := validJournal()
|
||||||
|
second := validJournal()
|
||||||
|
second.ID = "j2"
|
||||||
|
report, err := Run(context.Background(), repositoryStub{pages: [][]ledger.Journal{{first, second}}}, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if report.Valid != 1 || len(report.Invalid) != 1 {
|
||||||
|
t.Fatalf("unexpected report: %+v", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,11 +10,13 @@ import (
|
|||||||
|
|
||||||
"gl/application/explorer"
|
"gl/application/explorer"
|
||||||
"gl/infrastructure/config"
|
"gl/infrastructure/config"
|
||||||
|
"gl/infrastructure/observability"
|
||||||
"gl/infrastructure/postgres"
|
"gl/infrastructure/postgres"
|
||||||
webadapter "gl/interface/web"
|
webadapter "gl/interface/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
observability.Configure("gl-dashboard", slog.LevelInfo)
|
||||||
configPath := flag.String("conf", "./dashboard.cfg.toml", "path to the dashboard TOML configuration file")
|
configPath := flag.String("conf", "./dashboard.cfg.toml", "path to the dashboard TOML configuration file")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import (
|
|||||||
"gl/application/health"
|
"gl/application/health"
|
||||||
applicationledger "gl/application/ledger"
|
applicationledger "gl/application/ledger"
|
||||||
"gl/infrastructure/config"
|
"gl/infrastructure/config"
|
||||||
|
"gl/infrastructure/observability"
|
||||||
"gl/infrastructure/postgres"
|
"gl/infrastructure/postgres"
|
||||||
grpcadapter "gl/interface/grpc"
|
grpcadapter "gl/interface/grpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
observability.Configure("gl", slog.LevelInfo)
|
||||||
configPath := flag.String("conf", "./gl.cfg.toml", "path to the TOML configuration file")
|
configPath := flag.String("conf", "./gl.cfg.toml", "path to the TOML configuration file")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ environment = "local"
|
|||||||
|
|
||||||
[http]
|
[http]
|
||||||
host = "0.0.0.0"
|
host = "0.0.0.0"
|
||||||
port = 8080
|
port = 8601
|
||||||
read-header-timeout = "5s"
|
read-header-timeout = "5s"
|
||||||
shutdown-timeout = "10s"
|
shutdown-timeout = "10s"
|
||||||
|
|
||||||
|
|||||||
@@ -13,30 +13,20 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
|
|
||||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
|
||||||
github.com/cli/browser v1.3.0 // indirect
|
|
||||||
github.com/fatih/color v1.16.0 // indirect
|
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
github.com/knadh/koanf/maps v0.1.2 // indirect
|
github.com/knadh/koanf/maps v0.1.2 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
github.com/natefinch/atomic v1.0.1 // indirect
|
|
||||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/mod v0.32.0 // indirect
|
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/tools v0.41.0 // indirect
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
|
|
||||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
|
|
||||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
|
||||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
|
||||||
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
|
|
||||||
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
|
||||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
@@ -35,17 +25,10 @@ github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP
|
|||||||
github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
|
github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
|
||||||
github.com/knadh/koanf/v2 v2.3.4 h1:fnynNSDlujWE+v83hAp8wKr/cdoxHLO0629SN+U8Urc=
|
github.com/knadh/koanf/v2 v2.3.4 h1:fnynNSDlujWE+v83hAp8wKr/cdoxHLO0629SN+U8Urc=
|
||||||
github.com/knadh/koanf/v2 v2.3.4/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
|
github.com/knadh/koanf/v2 v2.3.4/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||||
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
|
|
||||||
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
|
|
||||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
@@ -57,20 +40,14 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
|||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
|
||||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
|
||||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
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.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||||
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
|
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Configure installs the process-wide JSON logger used by every GL adapter.
|
||||||
|
func Configure(service string, level slog.Leveler) *slog.Logger {
|
||||||
|
handler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
|
||||||
|
AddSource: true,
|
||||||
|
Level: level,
|
||||||
|
ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr {
|
||||||
|
switch attr.Key {
|
||||||
|
case slog.TimeKey:
|
||||||
|
attr.Key = "timestamp"
|
||||||
|
case slog.LevelKey:
|
||||||
|
attr.Key = "severity"
|
||||||
|
case slog.MessageKey:
|
||||||
|
attr.Key = "message"
|
||||||
|
case slog.SourceKey:
|
||||||
|
attr.Key = "source"
|
||||||
|
}
|
||||||
|
return attr
|
||||||
|
},
|
||||||
|
})
|
||||||
|
logger := slog.New(handler).With("service", service)
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
return logger
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ func TestHealth(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !response.Serving || response.DatabaseReady {
|
if response.Serving || response.DatabaseReady {
|
||||||
t.Fatalf("unexpected response: %+v", response)
|
t.Fatalf("unexpected response: %+v", response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,17 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
"runtime/debug"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
ledgerv1 "gl/gen/ledger/v1"
|
ledgerv1 "gl/gen/ledger/v1"
|
||||||
|
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/reflection"
|
"google.golang.org/grpc/reflection"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
@@ -30,7 +34,7 @@ func Run(ctx context.Context, cfg ServerConfig, handler ledgerv1.GeneralLedgerSe
|
|||||||
func runWithListener(ctx context.Context, shutdownTimeout time.Duration, listener net.Listener, handler ledgerv1.GeneralLedgerServiceServer) error {
|
func runWithListener(ctx context.Context, shutdownTimeout time.Duration, listener net.Listener, handler ledgerv1.GeneralLedgerServiceServer) error {
|
||||||
defer listener.Close()
|
defer listener.Close()
|
||||||
|
|
||||||
server := grpc.NewServer()
|
server := grpc.NewServer(grpc.ChainUnaryInterceptor(structuredUnaryLogger(), panicRecovery()))
|
||||||
ledgerv1.RegisterGeneralLedgerServiceServer(server, handler)
|
ledgerv1.RegisterGeneralLedgerServiceServer(server, handler)
|
||||||
reflection.Register(server)
|
reflection.Register(server)
|
||||||
|
|
||||||
@@ -67,3 +71,39 @@ func runWithListener(ctx context.Context, shutdownTimeout time.Duration, listene
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func structuredUnaryLogger() grpc.UnaryServerInterceptor {
|
||||||
|
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||||
|
started := time.Now()
|
||||||
|
response, err := handler(ctx, req)
|
||||||
|
code := status.Code(err)
|
||||||
|
level := slog.LevelInfo
|
||||||
|
if code != codes.OK {
|
||||||
|
level = slog.LevelError
|
||||||
|
}
|
||||||
|
slog.Log(ctx, level, "grpc call finished",
|
||||||
|
"component", "grpc_server",
|
||||||
|
"grpc_method", info.FullMethod,
|
||||||
|
"grpc_code", code.String(),
|
||||||
|
"duration_ms", float64(time.Since(started).Microseconds())/1000,
|
||||||
|
)
|
||||||
|
return response, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func panicRecovery() grpc.UnaryServerInterceptor {
|
||||||
|
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (response any, err error) {
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
slog.ErrorContext(ctx, "grpc panic recovered",
|
||||||
|
"component", "grpc_server",
|
||||||
|
"grpc_method", info.FullMethod,
|
||||||
|
"panic", recovered,
|
||||||
|
"stack", string(debug.Stack()),
|
||||||
|
)
|
||||||
|
err = status.Error(codes.Internal, "internal server error")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return handler(ctx, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user