Skip to content

Commit a73977d

Browse files
committed
feat: add OpenTelemetry Metrics support and remove global accessors
BREAKING CHANGES: - Removed SetGlobal, GetGlobal, and global helper functions - Users must use instance methods (app.Info, app.Tracer, app.Meter) Features: - Add ion.MetricsConfig for OTel Metrics (OTLP Push) - Add app.Meter(name) method for creating instruments - Add enterprise-grade defaults (ratio:0.1 sampling, 15s metrics interval) - Add Metrics example in examples/basic/ Docs: - Updated README with Metrics configuration reference - Updated doc.go package description
1 parent 11926db commit a73977d

12 files changed

Lines changed: 369 additions & 232 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,8 @@
11
.DS_Store
22
ion-release/
3+
4+
# Compiled Go binaries (named after directory)
5+
/basic
6+
examples/basic/basic
7+
examples/otel-test/otel-test
8+
*.exe

README.md

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ Ion is built on strict operational guarantees. Operators can rely on these invar
1919
## Non-Goals
2020

2121
To maintain focus and stability, Ion explicitly avoids:
22-
* **Metrics**: Use the Prometheus or OpenTelemetry Metrics SDKs directly.
2322
* **Alerting**: Ion emits signals; it does not manage thresholds or paging.
2423
* **Framework Magic**: Ion does not auto-inject into HTTP handlers without explicit middleware usage.
2524

@@ -116,6 +115,7 @@ Ion uses a comprehensive configuration struct for behavior control. This maps 1:
116115
| `File` | `FileConfig` | `Enabled: false` | configuration for file logging (with rotation). |
117116
| `OTEL` | `OTELConfig` | `Enabled: false` | configuration for remote OpenTelemetry logging. |
118117
| `Tracing` | `TracingConfig` | `Enabled: false` | configuration for Distributed Tracing. |
118+
| `Metrics` | `MetricsConfig` | `Enabled: false` | configuration for OpenTelemetry Metrics. |
119119

120120
### Console Configuration (`ion.ConsoleConfig`)
121121
| Field | Type | Default | Description |
@@ -157,11 +157,24 @@ Controls the OpenTelemetry **Trace** Provider.
157157
|-------|------|---------|-------------|
158158
| `Enabled` | `bool` | `false` | Enables trace generation and export. |
159159
| `Endpoint` | `string` | `""` | `host:port`. **Inherits** `OTEL.Endpoint` if empty. |
160-
| `Sampler` | `string` | `"always"` | `"always"`, `"never"`, or `"ratio:0.X"` (e.g., `ratio:0.1` for 10%). |
160+
| `Sampler` | `string` | `"ratio:0.1"` | `"always"`, `"never"`, or `"ratio:0.X"` (e.g., `ratio:0.1` for 10%). Development mode uses `"always"`. |
161161
| `Protocol` | `string` | `"grpc"` | `"grpc"` or `"http"`. **Inherits** `OTEL.Protocol` if empty. |
162162
| `Username` | `string` | `""` | **Inherits** `OTEL.Username` if empty. |
163163
| `Password` | `string` | `""` | **Inherits** `OTEL.Password` if empty. |
164164

165+
### Metrics Configuration (`ion.MetricsConfig`)
166+
Controls the OpenTelemetry **Metrics** Provider (OTLP Push).
167+
168+
| Field | Type | Default | Description |
169+
|-------|------|---------|-------------|
170+
| `Enabled` | `bool` | `false` | Enables metrics export. |
171+
| `Endpoint` | `string` | `""` | `host:port`. **Inherits** `OTEL.Endpoint` if empty. |
172+
| `Interval` | `Duration` | `15s` | Push interval. Development mode uses `5s`. |
173+
| `Temporality` | `string` | `"cumulative"` | `"cumulative"` (Prometheus-compatible) or `"delta"`. |
174+
| `Protocol` | `string` | `"grpc"` | **Inherits** `OTEL.Protocol` if empty. |
175+
| `Username` | `string` | `""` | **Inherits** `OTEL.Username` if empty. |
176+
| `Password` | `string` | `""` | **Inherits** `OTEL.Password` if empty. |
177+
165178
---
166179

167180
## Detailed Initialization
@@ -482,27 +495,6 @@ Operators must understand how Ion behaves under stress:
482495

483496
---
484497

485-
## Globals & Dependency Injection
486-
487-
> **⚠️ WARNING**: Global state is strictly for migration and legacy support.
488-
489-
**Do not** use `ion.SetGlobal` in new libraries or microservices.
490-
**Do** inject `ion.Logger` via struct constructors.
491-
492-
```go
493-
// CORRECT: Dependency Injection
494-
type Server struct {
495-
log ion.Logger
496-
}
497-
498-
// INCORRECT: Hidden dependency
499-
func (s *Server) Handle() {
500-
ion.Info(...) // Relies on global state
501-
}
502-
```
503-
504-
If `ion.GetGlobal()` is called without initialization, it returns a **safe no-op logger**. It will not panic, but your logs will be silently discarded to protect the runtime.
505-
506498
---
507499

508500
## Best Practices

config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ type OTELConfig = config.OTELConfig
2020
// TracingConfig configures distributed tracing.
2121
type TracingConfig = config.TracingConfig
2222

23+
// MetricsConfig configures OpenTelemetry metrics export.
24+
type MetricsConfig = config.MetricsConfig
25+
2326
// Default returns a Config with sensible production defaults.
2427
func Default() Config {
2528
return config.Default()

doc.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
1-
// Package ion provides production-grade logging and tracing for Go services.
1+
// Package ion provides production-grade logging, tracing, and metrics for Go services.
22
//
3-
// Ion unifies structured logging (Zap) and distributed tracing
3+
// Ion unifies structured logging (Zap), distributed tracing, and metrics
44
// (OpenTelemetry) behind a minimal, context-first API.
55
//
66
// # Guarantees
77
//
88
// - Process Safety: Ion never terminates the process (no os.Exit, no panic).
9-
// - Concurrency: All Logger and Tracer APIs are safe for concurrent use.
9+
// - Concurrency: All Logger, Tracer, and Meter APIs are safe for concurrent use.
1010
// - Failure Isolation: Telemetry backend failures never crash application logic.
1111
// - Lifecycle: Shutdown(ctx) flushes all buffers on a best-effort basis.
1212
//
1313
// # Architecture
1414
//
1515
// - Logs: Synchronous, structured, strongly typed.
1616
// - Traces: Asynchronous, sampled, batched.
17+
// - Metrics: Asynchronous, pushed via OTLP.
1718
// - Correlation: Automatic injection of trace_id/span_id from context.Context.
1819
//
1920
// Ion is designed for long-running services and distributed systems.
20-
// It is not a metrics SDK or a web framework.
2121
package ion

docs/USER_GUIDE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,12 +156,12 @@ In a blockchain node, distinct subsystems (P2P, Consensus, Mempool) need distinc
156156
157157
```go
158158
// main.go (Node Entrypoint)
159-
root := ion.New(cfg) // name="ion-node"
159+
app, _, _ := ion.New(cfg) // name="ion-node"
160160
161161
// Inject scoped loggers into major components
162-
p2pServer := p2p.NewServer(root.Named("p2p")) // name="ion-node.p2p"
163-
consensus := engine.New(root.Named("consensus")) // name="ion-node.consensus"
164-
mempool := pool.New(root.Named("mempool")) // name="ion-node.mempool"
162+
p2pServer := p2p.NewServer(app.Named("p2p")) // name="ion-node.p2p"
163+
consensus := engine.New(app.Named("consensus")) // name="ion-node.consensus"
164+
mempool := pool.New(app.Named("mempool")) // name="ion-node.mempool"
165165
```
166166
167167
```go

examples/basic/main.go

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -96,26 +96,38 @@ func example3_ChildLoggers() {
9696
}
9797

9898
// ============================================================================
99-
// Example 4: Global Usage Pattern
100-
// For scripts or legacy code where DI is impractical.
99+
// Example 4: Metrics
100+
// Demonstrates OpenTelemetry metrics instrumentation.
101101
// ============================================================================
102102

103-
func example4_GlobalUsage() {
103+
func example4_Metrics() {
104104
ctx := context.Background()
105105

106-
app, _, _ := ion.New(ion.Default().WithService("script"))
107-
ion.SetGlobal(app)
108-
defer ion.Sync()
106+
cfg := ion.Default().WithService("metrics-demo")
107+
cfg.Metrics.Enabled = true
108+
cfg.Metrics.Endpoint = "localhost:4317" // OTel Collector
109+
cfg.Metrics.Protocol = "grpc"
110+
cfg.Metrics.Insecure = true
109111

110-
// Now use package-level functions anywhere
111-
ion.Info(ctx, "using global logger")
112-
ion.Debug(ctx, "debug from anywhere")
112+
app, _, err := ion.New(cfg)
113+
if err != nil {
114+
log.Fatalf("Failed to create ion: %v", err)
115+
}
116+
defer app.Shutdown(ctx)
117+
118+
// Get a named meter
119+
meter := app.Meter("example.metrics")
113120

114-
// Get tracer from global too
115-
tracer := ion.GetTracer("script.process")
116-
ctx, span := tracer.Start(ctx, "DoWork")
117-
ion.Info(ctx, "inside span") // Has trace_id, span_id
118-
span.End()
121+
// Create instruments
122+
requestCounter, _ := meter.Int64Counter("http_requests_total") // metric.WithDescription("Total HTTP requests"),
123+
124+
latencyHist, _ := meter.Float64Histogram("http_request_duration_seconds") // metric.WithDescription("HTTP request latency"),
125+
126+
// Record metrics
127+
requestCounter.Add(ctx, 1)
128+
latencyHist.Record(ctx, 0.025) // 25ms
129+
130+
app.Info(ctx, "metrics recorded")
119131
}
120132

121133
// ============================================================================
@@ -189,9 +201,6 @@ func example6_ProductionSetup() {
189201
log.Printf("ion warning: %v", w)
190202
}
191203

192-
// Set as global for convenience
193-
ion.SetGlobal(app)
194-
195204
// CRITICAL: Graceful shutdown to flush all logs and traces
196205
defer func() {
197206
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -251,8 +260,8 @@ func main() {
251260
fmt.Println("\n=== Example 3: Child Loggers ===")
252261
example3_ChildLoggers()
253262

254-
fmt.Println("\n=== Example 4: Global Usage ===")
255-
example4_GlobalUsage()
263+
fmt.Println("\n=== Example 4: Metrics ===")
264+
example4_Metrics()
256265

257266
fmt.Println("\n=== Example 5: Blockchain Fields ===")
258267
example5_BlockchainFields()

go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@ require (
99
go.opentelemetry.io/otel v1.39.0
1010
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0
1111
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0
12+
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0
13+
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0
1214
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0
1315
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0
1416
go.opentelemetry.io/otel/log v0.15.0
17+
go.opentelemetry.io/otel/metric v1.39.0
1518
go.opentelemetry.io/otel/sdk v1.39.0
1619
go.opentelemetry.io/otel/sdk/log v0.15.0
20+
go.opentelemetry.io/otel/sdk/metric v1.39.0
1721
go.opentelemetry.io/otel/trace v1.39.0
1822
go.uber.org/zap v1.27.1
1923
google.golang.org/grpc v1.77.0
@@ -30,7 +34,6 @@ require (
3034
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
3135
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
3236
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect
33-
go.opentelemetry.io/otel/metric v1.39.0 // indirect
3437
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
3538
go.uber.org/multierr v1.11.0 // indirect
3639
golang.org/x/net v0.47.0 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0 h1:W+m0g+/6v
3737
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0/go.mod h1:JM31r0GGZ/GU94mX8hN4D8v6e40aFlUECSQ48HaLgHM=
3838
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0 h1:EKpiGphOYq3CYnIe2eX9ftUkyU+Y8Dtte8OaWyHJ4+I=
3939
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0/go.mod h1:nWFP7C+T8TygkTjJ7mAyEaFaE7wNfms3nV/vexZ6qt0=
40+
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 h1:cEf8jF6WbuGQWUVcqgyWtTR0kOOAWY1DYZ+UhvdmQPw=
41+
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0/go.mod h1:k1lzV5n5U3HkGvTCJHraTAGJ7MqsgL1wrGwTj1Isfiw=
42+
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0 h1:nKP4Z2ejtHn3yShBb+2KawiXgpn8In5cT7aO2wXuOTE=
43+
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0/go.mod h1:NwjeBbNigsO4Aj9WgM0C+cKIrxsZUaRmZUO7A8I7u8o=
4044
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
4145
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
4246
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM=

internal/config/config.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ type Config struct {
3939

4040
// Tracing configuration for distributed tracing.
4141
Tracing TracingConfig `yaml:"tracing" json:"tracing"`
42+
43+
// Metrics configuration for OpenTelemetry metrics.
44+
Metrics MetricsConfig `yaml:"metrics" json:"metrics"`
4245
}
4346

4447
// ConsoleConfig configures console (stdout/stderr) output.
@@ -174,7 +177,48 @@ type TracingConfig struct {
174177
Attributes map[string]string `yaml:"attributes" json:"attributes"`
175178
}
176179

180+
// MetricsConfig configures OpenTelemetry metrics export.
181+
type MetricsConfig struct {
182+
// Enabled controls whether metrics export is active.
183+
Enabled bool `yaml:"enabled" json:"enabled"`
184+
185+
// Protocol: "grpc" or "http".
186+
Protocol string `yaml:"protocol" json:"protocol"`
187+
188+
// Endpoint is the OTEL collector endpoint for metrics.
189+
// Falls back to OTEL.Endpoint if not set.
190+
Endpoint string `yaml:"endpoint" json:"endpoint"`
191+
192+
// Insecure disables TLS.
193+
Insecure bool `yaml:"insecure" json:"insecure"`
194+
195+
// Username for Basic Authentication (optional).
196+
Username string `yaml:"username" json:"username" env:"METRICS_USERNAME"`
197+
198+
// Password for Basic Authentication (optional).
199+
Password string `yaml:"password" json:"password" env:"METRICS_PASSWORD"`
200+
201+
// Headers for authentication.
202+
Headers map[string]string `yaml:"headers" json:"headers"`
203+
204+
// Timeout for export.
205+
Timeout time.Duration `yaml:"timeout" json:"timeout"`
206+
207+
// Interval is the push interval for metrics.
208+
// Default: 15s
209+
Interval time.Duration `yaml:"interval" json:"interval"`
210+
211+
// Temporality preference: "cumulative" (default) or "delta".
212+
// Prometheus prefers Cumulative.
213+
Temporality string `yaml:"temporality" json:"temporality"`
214+
215+
// Attributes for metrics resource.
216+
Attributes map[string]string `yaml:"attributes" json:"attributes"`
217+
}
218+
177219
// Default returns a Config with sensible production defaults.
220+
// All telemetry backends (OTEL, Tracing, Metrics) are disabled by default.
221+
// When enabled, they inherit endpoint/auth from OTEL config automatically.
178222
func Default() Config {
179223
return Config{
180224
Level: "info",
@@ -201,15 +245,46 @@ func Default() Config {
201245
BatchSize: 512,
202246
ExportInterval: 5 * time.Second,
203247
},
248+
Tracing: TracingConfig{
249+
Enabled: false,
250+
Sampler: "ratio:0.1", // 10% sampling for production (safe default)
251+
BatchSize: 512,
252+
ExportInterval: 5 * time.Second,
253+
// Endpoint, Protocol, Auth inherited from OTEL if empty
254+
},
255+
Metrics: MetricsConfig{
256+
Enabled: false,
257+
Interval: 15 * time.Second, // Standard OTel push interval
258+
Temporality: "cumulative", // Prometheus-compatible
259+
// Endpoint, Protocol, Auth inherited from OTEL if empty
260+
},
204261
}
205262
}
206263

207264
// Development returns a Config optimized for development.
265+
// - Debug level logging with pretty console output.
266+
// - Tracing pre-configured with "always" sampling (but disabled by default).
267+
// - Metrics pre-configured with 5s push interval (but disabled by default).
268+
//
269+
// To enable observability, you must explicitly set:
270+
//
271+
// cfg.OTEL.Enabled = true // For remote log export
272+
// cfg.OTEL.Endpoint = "localhost:4317"
273+
// cfg.OTEL.Insecure = true // For local dev
274+
// cfg.Tracing.Enabled = true // For distributed tracing
275+
// cfg.Metrics.Enabled = true // For metrics export
208276
func Development() Config {
209277
cfg := Default()
210278
cfg.Level = "debug"
211279
cfg.Development = true
212280
cfg.Console.Format = "pretty"
281+
282+
// Dev-friendly tracing: sample everything
283+
cfg.Tracing.Sampler = "always"
284+
285+
// Dev-friendly metrics: faster feedback loop
286+
cfg.Metrics.Interval = 5 * time.Second
287+
213288
return cfg
214289
}
215290

@@ -248,6 +323,15 @@ func (c Config) WithTracing(endpoint string) Config {
248323
return c
249324
}
250325

326+
// WithMetrics returns a copy of the config with metrics enabled.
327+
func (c Config) WithMetrics(endpoint string) Config {
328+
c.Metrics.Enabled = true
329+
if endpoint != "" {
330+
c.Metrics.Endpoint = endpoint
331+
}
332+
return c
333+
}
334+
251335
// NewFileWriter creates a log file writer with rotation.
252336
func NewFileWriter(cfg FileConfig) io.Writer {
253337
return &lumberjack.Logger{
@@ -307,6 +391,19 @@ func (c Config) Validate() error {
307391
errs = append(errs, fmt.Sprintf("invalid tracing protocol %q (use: grpc, http)", c.Tracing.Protocol))
308392
}
309393

394+
// Validate metrics config
395+
if c.Metrics.Enabled {
396+
if c.Metrics.Endpoint == "" && c.OTEL.Endpoint == "" {
397+
errs = append(errs, "metrics enabled but no endpoint (set Metrics.Endpoint or OTEL.Endpoint)")
398+
}
399+
if c.Metrics.Protocol != "" && c.Metrics.Protocol != "grpc" && c.Metrics.Protocol != "http" {
400+
errs = append(errs, fmt.Sprintf("invalid metrics protocol %q (use: grpc, http)", c.Metrics.Protocol))
401+
}
402+
if c.Metrics.Temporality != "" && c.Metrics.Temporality != "cumulative" && c.Metrics.Temporality != "delta" {
403+
errs = append(errs, fmt.Sprintf("invalid metrics temporality %q (use: cumulative, delta)", c.Metrics.Temporality))
404+
}
405+
}
406+
310407
if len(errs) > 0 {
311408
return fmt.Errorf("config validation failed: %s", strings.Join(errs, "; "))
312409
}

0 commit comments

Comments
 (0)