Skip to content

Commit 1b2e740

Browse files
committed
feat: Add observability package
1 parent 360f417 commit 1b2e740

19 files changed

+1404
-0
lines changed

packages/observability/.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# https://dart.dev/guides/libraries/private-files
2+
# Created by `dart pub`
3+
.dart_tool/
4+
5+
# Avoid committing pubspec.lock for library packages; see
6+
# https://dart.dev/guides/libraries/private-files#pubspeclock.
7+
pubspec.lock
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
## 0.1.0
2+
3+
- Initial version.

packages/observability/LICENSE

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
Copyright (c) 2025 The Celest project authors
2+
3+
Redistribution and use in source and binary forms, with or without modification,
4+
are permitted provided that the following conditions are met:
5+
6+
1. Redistributions of source code must retain the above copyright notice, this
7+
list of conditions and the following disclaimer.
8+
9+
2. Redistributions in binary form must reproduce the above copyright notice,
10+
this list of conditions and the following disclaimer in the documentation and/or
11+
other materials provided with the distribution.
12+
13+
Subject to the terms and conditions of this license, each copyright holder and
14+
contributor hereby grants to those receiving rights under this license a
15+
perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
16+
(except for failure to satisfy the conditions of this license) patent license to
17+
make, have made, use, offer to sell, sell, import, and otherwise transfer this
18+
software, where such license applies only to those patent claims, already
19+
acquired or hereafter acquired, licensable by such copyright holder or
20+
contributor that are necessarily infringed by:
21+
22+
(a) their Contribution(s) (the licensed copyrights of copyright holders and
23+
non-copyrightable additions of contributors, in source or binary form) alone; or
24+
25+
(b) combination of their Contribution(s) with the work of authorship to which
26+
such Contribution(s) was added by such copyright holder or contributor, if, at
27+
the time the Contribution is added, such addition causes such combination to be
28+
necessarily infringed. The patent license shall not apply to any other
29+
combinations which include the Contribution.
30+
31+
Except as expressly stated above, no rights or licenses from any copyright
32+
holder or contributor is granted under this license, whether expressly, by
33+
implication, estoppel or otherwise.
34+
35+
DISCLAIMER
36+
37+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND
38+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
39+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
40+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE
41+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
42+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
43+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
44+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
45+
TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
46+
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

packages/observability/README.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
## Observability
2+
3+
Utilities for instrumenting Celest services with structured metrics and
4+
lightweight tracing primitives. The package is backend agnostic; plug in your
5+
preferred recorder or tracer implementation and forward data to systems such as
6+
Prometheus, OpenTelemetry, or Honeycomb.
7+
8+
## Features
9+
10+
- Thin abstractions for counters and histograms with structured attribute maps.
11+
- Global registries (`GlobalMetrics`, `GlobalTracer`) for late-bound DI wiring.
12+
- Helper APIs for measuring latency and running traced closures.
13+
14+
## Getting started
15+
16+
Add the dependency to your `pubspec.yaml` (most Celest repositories already do
17+
this):
18+
19+
```yaml
20+
dependencies:
21+
observability:
22+
path: ../../packages/observability
23+
```
24+
25+
Install your recorder/tracer early during service start-up:
26+
27+
```dart
28+
Future<void> bootstrap() async {
29+
final recorder = PrometheusRecorder(/* config */);
30+
GlobalMetrics.install(MetricsRegistry(recorder: recorder));
31+
32+
final tracer = OpenTelemetryTracer(/* config */);
33+
GlobalTracer.install(TracerRegistry(tracer: tracer));
34+
}
35+
```
36+
37+
## Usage
38+
39+
Create structured helpers when emitting metrics:
40+
41+
```dart
42+
final StructuredCounter requestCounter = StructuredCounter(
43+
recorder: GlobalMetrics.instance.recorder,
44+
name: 'http_requests_total',
45+
description: 'Requests grouped by method.',
46+
);
47+
48+
requestCounter.increment(attributes: {'method': 'GET'});
49+
```
50+
51+
Measure latency without hand-rolled stopwatches:
52+
53+
```dart
54+
final histogram = GlobalMetrics.instance.recorder.histogram(
55+
'db_latency_ms',
56+
description: 'Database round-trip latency in milliseconds.',
57+
);
58+
59+
await recordLatency(
60+
histogram: histogram,
61+
attributes: {'operation': 'insert'},
62+
run: () async => await db.insert(row),
63+
);
64+
```
65+
66+
Wrap business logic in spans to collect trace data:
67+
68+
```dart
69+
await GlobalTracer.instance.tracer.trace<void>(
70+
name: 'orders.create',
71+
attributes: {'route': '/v1/orders'},
72+
run: (span) async {
73+
span.addEvent('validation.start');
74+
await orderValidator.validate(payload);
75+
span.addEvent('validation.complete');
76+
span.setStatus(SpanStatus.ok);
77+
},
78+
);
79+
```
80+
81+
## Example
82+
83+
A runnable example that prints metrics and spans to stdout lives under
84+
[`example/observability_example.dart`](example/observability_example.dart).
85+
86+
## Contributing
87+
88+
File issues and PRs in the main [celest](https://github.com/celest-dev/celest)
89+
repository. Please include relevant logs or traces when reporting
90+
observability-related bugs.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
include: package:celest_lints/library.yaml
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// ignore_for_file: avoid_print, omit_obvious_local_variable_types
2+
3+
import 'dart:async';
4+
5+
import 'package:observability/observability.dart';
6+
7+
/// Simple example that wires up custom metric and tracing recorders and emits
8+
/// a few demo signals. In a production setting these recorders would forward
9+
/// data to systems such as OpenTelemetry, Prometheus, or Honeycomb.
10+
Future<void> main() async {
11+
// Install global registries so framework code can locate the recorders.
12+
GlobalMetrics.install(MetricsRegistry(recorder: _PrintMetricRecorder()));
13+
GlobalTracer.install(TracerRegistry(tracer: _PrintTracer()));
14+
15+
final MetricRecorder recorder = GlobalMetrics.instance.recorder;
16+
17+
// Create structured helpers that attach consistent attributes.
18+
final StructuredCounter requestCounter = StructuredCounter(
19+
recorder: recorder,
20+
name: 'example_requests_total',
21+
description: 'Number of demo requests by method.',
22+
);
23+
24+
final StructuredCounter cacheCounter = StructuredCounter(
25+
recorder: recorder,
26+
name: 'example_cache_outcome_total',
27+
description: 'Cache results grouped by outcome.',
28+
);
29+
30+
final Histogram latencyHistogram = recorder.histogram(
31+
'example_request_latency_ms',
32+
description: 'Latency for demo request handling in milliseconds.',
33+
);
34+
35+
final Tracer tracer = GlobalTracer.instance.tracer;
36+
37+
// Emit observability signals around some fake work.
38+
await tracer.trace<void>(
39+
name: 'example.request',
40+
attributes: {'route': '/hello', 'method': 'GET'},
41+
run: (Span span) async {
42+
requestCounter.increment(attributes: {'method': 'GET'});
43+
44+
await recordLatency(
45+
histogram: latencyHistogram,
46+
attributes: {'route': '/hello'},
47+
run: () async {
48+
span.addEvent('work.started');
49+
await Future<void>.delayed(const Duration(milliseconds: 12));
50+
span.addEvent('work.finished');
51+
},
52+
);
53+
54+
cacheCounter.increment(attributes: {'outcome': 'miss'});
55+
56+
span.setStatus(SpanStatus.ok);
57+
},
58+
);
59+
}
60+
61+
class _PrintMetricRecorder implements MetricRecorder {
62+
@override
63+
Counter counter(String name, {String? description}) {
64+
return _PrintCounter(name, description: description);
65+
}
66+
67+
@override
68+
Histogram histogram(String name, {String? description}) {
69+
return _PrintHistogram(name, description: description);
70+
}
71+
}
72+
73+
class _PrintCounter implements Counter {
74+
_PrintCounter(this.name, {this.description});
75+
76+
final String name;
77+
final String? description;
78+
79+
@override
80+
void add(num value, {Map<String, String>? attributes}) {
81+
print(
82+
'counter<$name>${description != null ? ' ($description)' : ''}: '
83+
'value=$value attributes=$attributes',
84+
);
85+
}
86+
}
87+
88+
class _PrintHistogram implements Histogram {
89+
_PrintHistogram(this.name, {this.description});
90+
91+
final String name;
92+
final String? description;
93+
94+
@override
95+
void record(num value, {Map<String, String>? attributes}) {
96+
print(
97+
'histogram<$name>${description != null ? ' ($description)' : ''}: '
98+
'value=$value attributes=$attributes',
99+
);
100+
}
101+
}
102+
103+
class _PrintTracer extends Tracer {
104+
@override
105+
Span startSpan(
106+
String name, {
107+
SpanContext? parent,
108+
Map<String, Object?>? attributes,
109+
}) {
110+
print('span<$name> start attributes=$attributes');
111+
return _PrintSpan(name, parent: parent);
112+
}
113+
}
114+
115+
class _PrintSpan implements Span {
116+
_PrintSpan(this.name, {SpanContext? parent}) : _context = _derive(parent);
117+
118+
final String name;
119+
final SpanContext _context;
120+
SpanStatus _status = SpanStatus.unset;
121+
122+
@override
123+
SpanContext get context => _context;
124+
125+
@override
126+
void addEvent(String eventName, {Map<String, Object?>? attributes}) {
127+
print('span<$name> event=$eventName attributes=$attributes');
128+
}
129+
130+
@override
131+
void end({SpanStatus status = SpanStatus.unset}) {
132+
_status = status == SpanStatus.unset ? _status : status;
133+
print('span<$name> end status=$_status');
134+
}
135+
136+
@override
137+
void recordError(Object error, StackTrace stackTrace) {
138+
print('span<$name> error=$error stackTrace=$stackTrace');
139+
_status = SpanStatus.error;
140+
}
141+
142+
@override
143+
void setAttribute(String key, Object? value) {
144+
print('span<$name> attr $key=$value');
145+
}
146+
147+
@override
148+
void setStatus(SpanStatus status, {String? description}) {
149+
_status = status;
150+
print('span<$name> status=$status description=$description');
151+
}
152+
153+
static SpanContext _derive(SpanContext? parent) {
154+
final String traceId = parent?.traceId ?? 'example-trace';
155+
final String spanId = DateTime.now().microsecondsSinceEpoch.toString();
156+
return SpanContext(traceId: traceId, spanId: spanId);
157+
}
158+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// ignore_for_file: unnecessary_library_name
2+
3+
/// Observability primitives for Celest services.
4+
library observability;
5+
6+
export 'src/counters.dart';
7+
export 'src/histograms.dart';
8+
export 'src/logging.dart';
9+
export 'src/metrics.dart';
10+
export 'src/registry.dart';
11+
export 'src/tracing.dart';
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import 'package:observability/src/metrics.dart';
2+
3+
/// A convenience wrapper around [Counter] that standardises metric creation and
4+
/// exposes expressive helper methods.
5+
///
6+
/// Typical usage installs a [MetricRecorder] during service start-up and then
7+
/// creates a `StructuredCounter` to emit
8+
/// instrumented data:
9+
///
10+
/// ```dart
11+
/// final recorder = obtainMetricRecorder();
12+
/// final counter = StructuredCounter(
13+
/// recorder: recorder,
14+
/// name: 'http_requests_total',
15+
/// description: 'Requests grouped by method.',
16+
/// );
17+
///
18+
/// counter.increment(attributes: {'method': 'GET'});
19+
/// counter.add(5, attributes: {'method': 'POST'});
20+
/// ```
21+
class StructuredCounter {
22+
/// Creates a counter named [name] using the provided [recorder].
23+
StructuredCounter({
24+
required MetricRecorder recorder,
25+
required String name,
26+
String? description,
27+
}) : _counter = recorder.counter(name, description: description);
28+
29+
final Counter _counter;
30+
31+
/// Increments the counter by 1 with optional [attributes].
32+
void increment({Map<String, String>? attributes}) =>
33+
_counter.add(1, attributes: attributes);
34+
35+
/// Adds an arbitrary [value] to the counter with optional [attributes].
36+
void add(num value, {Map<String, String>? attributes}) =>
37+
_counter.add(value, attributes: attributes);
38+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import 'package:observability/src/metrics.dart';
2+
3+
/// Lightweight wrapper that produces a [Histogram] from a [MetricRecorder] and
4+
/// offers ergonomic helpers for recording numeric values.
5+
///
6+
/// ```dart
7+
/// final recorder = obtainMetricRecorder();
8+
/// final latency = StructuredHistogram(
9+
/// recorder: recorder,
10+
/// name: 'worker_latency_ms',
11+
/// description: 'Latency grouped by route.',
12+
/// );
13+
///
14+
/// latency.record(42, attributes: {'route': '/hello'});
15+
/// latency.recordDuration(const Duration(milliseconds: 12));
16+
/// ```
17+
class StructuredHistogram {
18+
/// Creates a histogram named [name] using the provided [recorder].
19+
StructuredHistogram({
20+
required MetricRecorder recorder,
21+
required String name,
22+
String? description,
23+
}) : _histogram = recorder.histogram(name, description: description);
24+
25+
final Histogram _histogram;
26+
27+
/// Records a numeric [value] with optional dimension attributes.
28+
void record(num value, {Map<String, String>? attributes}) {
29+
_histogram.record(value, attributes: attributes);
30+
}
31+
32+
/// Records the provided [duration] expressed in milliseconds.
33+
void recordDuration(Duration duration, {Map<String, String>? attributes}) {
34+
record(duration.inMilliseconds, attributes: attributes);
35+
}
36+
}

0 commit comments

Comments
 (0)