Line data Source code
1 : // Copyright The OpenTelemetry Authors
2 : // SPDX-License-Identifier: Apache-2.0
3 : // Copyright 2025, Dartastic.io, All rights reserved.
4 :
5 : /// Test helpers for the Dartastic OpenTelemetry SDK.
6 : ///
7 : /// Import this library from `test/` only:
8 : ///
9 : /// ```dart
10 : /// import 'package:dartastic_opentelemetry/testing.dart';
11 : /// ```
12 : ///
13 : /// It is intentionally **not** exported from the main
14 : /// `package:dartastic_opentelemetry/dartastic_opentelemetry.dart`
15 : /// barrel so production bundles don't carry the in-memory exporter
16 : /// classes.
17 : ///
18 : /// Provides:
19 : /// - [InMemorySpanExporter] — collects exported spans into a list with
20 : /// query helpers (`findSpanByName`, `findSpansByName`,
21 : /// `findSpansStartingWith`, `clear`).
22 : /// - [InMemoryLogExporter] — same idea for log records.
23 : /// - [InMemoryMetricExporter] — same idea for metrics; pair with
24 : /// [OnDemandMetricReader].
25 : /// - [OnDemandMetricReader] — a metric reader that never fires on a
26 : /// timer; tests drive `collect()` explicitly via
27 : /// [TestHarness.collectMetrics].
28 : /// - [TestHarness] — bundles the three exporters and the reader,
29 : /// plus a `clear()` to reset between tests.
30 : /// - [maybeInitializeOtelForTest] — singleton initializer designed
31 : /// for `setUpAll`. Brings up the real OpenTelemetry SDK pointed at
32 : /// the in-memory exporters. Safe to call from multiple test files
33 : /// in the same process; returns the same harness on subsequent
34 : /// calls.
35 : ///
36 : /// The shape mirrors the test harness used in the OTel-Dart reference
37 : /// demo at https://github.com/dartastic/dart-otel-reference-demo
38 : /// so wrappers, customer apps, and the reference demo all use the same
39 : /// scaffolding.
40 : library;
41 :
42 : import 'dart:async';
43 :
44 : import 'dartastic_opentelemetry.dart';
45 :
46 : /// Span exporter that buffers spans in memory for inspection.
47 : ///
48 : /// Tests typically:
49 : ///
50 : /// ```dart
51 : /// setUpAll(() async {
52 : /// harness = await maybeInitializeOtelForTest();
53 : /// spans = harness.spans;
54 : /// });
55 : /// setUp(() => harness.clear());
56 : ///
57 : /// test('my_op emits a span', () {
58 : /// doMyOp();
59 : /// expect(spans.findSpanByName('my_op'), isNotNull);
60 : /// });
61 : /// ```
62 : ///
63 : /// Use [findSpanByName] / [findSpansByName] / [findSpansStartingWith]
64 : /// instead of indexing into [spans] directly — they make test failures
65 : /// read more clearly.
66 : class InMemorySpanExporter implements SpanExporter {
67 : /// Creates an exporter.
68 1 : InMemorySpanExporter();
69 :
70 : final List<Span> _spans = <Span>[];
71 : bool _isShutdown = false;
72 :
73 : /// All spans exported since the last [clear].
74 3 : List<Span> get spans => List<Span>.unmodifiable(_spans);
75 :
76 : /// All exported span names, in export order. Handy in `expect`
77 : /// assertions when you want to check the trace shape.
78 6 : List<String> get spanNames => _spans.map((s) => s.name).toList();
79 :
80 : /// Clears the recorded spans. Call between tests.
81 3 : void clear() => _spans.clear();
82 :
83 : /// Returns the **most recently** exported span with the given
84 : /// [name], or `null` if none match. Most-recent makes per-test
85 : /// reads stable when a span name happens to be reused across tests.
86 1 : Span? findSpanByName(String name) {
87 5 : for (var i = _spans.length - 1; i >= 0; i--) {
88 6 : if (_spans[i].name == name) return _spans[i];
89 : }
90 : return null;
91 : }
92 :
93 : /// Returns every exported span whose name equals [name].
94 1 : List<Span> findSpansByName(String name) =>
95 6 : _spans.where((s) => s.name == name).toList(growable: false);
96 :
97 : /// Returns every exported span whose name starts with [prefix].
98 : /// Useful for category-level assertions
99 : /// (e.g. `findSpansStartingWith('http ')`).
100 1 : List<Span> findSpansStartingWith(String prefix) =>
101 6 : _spans.where((s) => s.name.startsWith(prefix)).toList(growable: false);
102 :
103 1 : @override
104 : Future<void> export(List<Span> spans) async {
105 1 : if (_isShutdown) {
106 0 : throw StateError('InMemorySpanExporter is shutdown');
107 : }
108 2 : _spans.addAll(spans);
109 : }
110 :
111 0 : @override
112 : Future<void> forceFlush() async {}
113 :
114 0 : @override
115 : Future<void> shutdown() async {
116 0 : _isShutdown = true;
117 : }
118 : }
119 :
120 : /// Log-record exporter that buffers records in memory for inspection.
121 : class InMemoryLogExporter implements LogRecordExporter {
122 : /// Creates an exporter.
123 1 : InMemoryLogExporter();
124 :
125 : final List<LogRecord> _records = <LogRecord>[];
126 : bool _isShutdown = false;
127 :
128 : /// All records exported since the last [clear].
129 3 : List<LogRecord> get records => List<LogRecord>.unmodifiable(_records);
130 :
131 : /// Clears the recorded log records. Call between tests.
132 3 : void clear() => _records.clear();
133 :
134 : /// Returns every record with the given [severity].
135 1 : List<LogRecord> findRecordsBySeverity(Severity severity) =>
136 6 : _records.where((r) => r.severityNumber == severity).toList();
137 :
138 1 : @override
139 : Future<ExportResult> export(List<LogRecord> r) async {
140 1 : if (_isShutdown) return ExportResult.failure;
141 2 : _records.addAll(r);
142 : return ExportResult.success;
143 : }
144 :
145 1 : @override
146 : Future<void> forceFlush() async {}
147 :
148 0 : @override
149 : Future<void> shutdown() async {
150 0 : _isShutdown = true;
151 : }
152 : }
153 :
154 : /// Metric exporter that buffers exported metric snapshots in memory.
155 : ///
156 : /// Use with [OnDemandMetricReader] so tests drive collection
157 : /// explicitly — tests rarely want a timer fighting their
158 : /// `expect` assertions.
159 : class InMemoryMetricExporter implements MetricExporter {
160 : /// Creates an exporter.
161 1 : InMemoryMetricExporter();
162 :
163 : final List<Metric> _metrics = <Metric>[];
164 : bool _isShutdown = false;
165 :
166 : /// All metrics exported since the last [clear].
167 3 : List<Metric> get metrics => List<Metric>.unmodifiable(_metrics);
168 :
169 : /// Clears the recorded metrics. Call between tests.
170 3 : void clear() => _metrics.clear();
171 :
172 : /// Returns the most-recently exported metric named [name], or
173 : /// `null` if none match.
174 1 : Metric? findMetricByName(String name) {
175 4 : for (var i = _metrics.length - 1; i >= 0; i--) {
176 6 : if (_metrics[i].name == name) return _metrics[i];
177 : }
178 : return null;
179 : }
180 :
181 1 : @override
182 : Future<bool> export(MetricData data) async {
183 1 : if (_isShutdown) return false;
184 3 : _metrics.addAll(data.metrics);
185 : return true;
186 : }
187 :
188 0 : @override
189 0 : Future<bool> forceFlush() async => !_isShutdown;
190 :
191 0 : @override
192 : Future<bool> shutdown() async {
193 0 : _isShutdown = true;
194 : return true;
195 : }
196 : }
197 :
198 : /// Metric reader for tests. Never fires on a timer; tests drive
199 : /// `collect()` explicitly via [TestHarness.collectMetrics] so
200 : /// assertions don't race against a periodic export.
201 : class OnDemandMetricReader extends MetricReader {
202 : /// Creates a reader that forwards collected metrics to [exporter].
203 1 : OnDemandMetricReader(this.exporter);
204 :
205 : /// The exporter that receives collected metrics.
206 : final MetricExporter exporter;
207 : bool _isShutdown = false;
208 :
209 1 : @override
210 : Future<MetricData> collect() async {
211 1 : final mp = meterProvider;
212 1 : if (mp == null || _isShutdown) {
213 0 : return MetricData.empty();
214 : }
215 1 : final metrics = await mp.collectAllMetrics();
216 2 : return MetricData(resource: mp.resource, metrics: metrics);
217 : }
218 :
219 0 : @override
220 : Future<bool> forceFlush() async {
221 0 : if (_isShutdown) return false;
222 0 : final data = await collect();
223 0 : if (data.metrics.isNotEmpty) {
224 0 : await exporter.export(data);
225 : }
226 0 : return await exporter.forceFlush();
227 : }
228 :
229 0 : @override
230 : Future<bool> shutdown() async {
231 0 : if (_isShutdown) return true;
232 0 : _isShutdown = true;
233 0 : return await exporter.shutdown();
234 : }
235 : }
236 :
237 : /// Bundles the three in-memory exporters and the on-demand metric
238 : /// reader so tests have one handle to `clear()` between cases.
239 : ///
240 : /// Construct via [maybeInitializeOtelForTest].
241 : class TestHarness {
242 : /// Creates a harness. Prefer [maybeInitializeOtelForTest] over
243 : /// calling this directly.
244 1 : TestHarness({
245 : required this.spans,
246 : required this.logs,
247 : required this.metrics,
248 : required this.metricReader,
249 : });
250 :
251 : /// The span exporter that received every emitted span.
252 : final InMemorySpanExporter spans;
253 :
254 : /// The log exporter that received every emitted log record.
255 : final InMemoryLogExporter logs;
256 :
257 : /// The metric exporter that received every emitted metric snapshot.
258 : /// New snapshots arrive only when you call [collectMetrics].
259 : final InMemoryMetricExporter metrics;
260 :
261 : /// The reader driving metric collection.
262 : final MetricReader metricReader;
263 :
264 : /// Pumps the meter provider and forwards collected metrics to the
265 : /// in-memory exporter. Call this in a test after recording metrics
266 : /// but before asserting on [metrics].
267 1 : Future<void> collectMetrics() async {
268 2 : final data = await metricReader.collect();
269 2 : await metrics.export(data);
270 : }
271 :
272 : /// Forces the SDK's logger pipeline to flush. Tests that emit logs
273 : /// synchronously usually don't need this, but it's safe to call.
274 1 : Future<void> flushLogs() async {
275 2 : await OTel.loggerProvider().forceFlush();
276 : }
277 :
278 : /// Resets all three exporters' buffers. Call from `setUp()`.
279 1 : void clear() {
280 2 : spans.clear();
281 2 : logs.clear();
282 2 : metrics.clear();
283 : }
284 : }
285 :
286 : TestHarness? _shared;
287 :
288 : /// Initializes the OpenTelemetry SDK once per test process with
289 : /// in-memory exporters for spans / logs / metrics, and returns the
290 : /// same [TestHarness] on subsequent calls.
291 : ///
292 : /// Designed for `setUpAll`:
293 : ///
294 : /// ```dart
295 : /// late TestHarness harness;
296 : /// late InMemorySpanExporter spans;
297 : ///
298 : /// setUpAll(() async {
299 : /// harness = await maybeInitializeOtelForTest(
300 : /// serviceName: 'my_wrapper-test',
301 : /// );
302 : /// spans = harness.spans;
303 : /// });
304 : ///
305 : /// setUp(() => harness.clear());
306 : /// ```
307 : ///
308 : /// Idempotent — calling it from multiple test files in the same
309 : /// process is fine; the SDK is initialized exactly once and every
310 : /// caller gets the same exporters.
311 : ///
312 : /// - [serviceName] becomes `service.name` on emitted telemetry.
313 : /// - [endpoint] is a dummy — no exporter actually hits the network.
314 : /// Override only if your code-under-test reads it.
315 1 : Future<TestHarness> maybeInitializeOtelForTest({
316 : String serviceName = 'otel-test',
317 : String endpoint = OTel.defaultEndpoint,
318 : }) async {
319 : if (_shared != null) return _shared!;
320 1 : final spanExporter = InMemorySpanExporter();
321 1 : final logExporter = InMemoryLogExporter();
322 1 : final metricExporter = InMemoryMetricExporter();
323 1 : final reader = OnDemandMetricReader(metricExporter);
324 1 : await OTel.initialize(
325 : endpoint: endpoint,
326 : serviceName: serviceName,
327 : serviceVersion: '0.0.0-test',
328 1 : spanProcessor: SimpleSpanProcessor(spanExporter),
329 1 : logRecordProcessor: SimpleLogRecordProcessor(logExporter),
330 : metricReader: reader,
331 : detectPlatformResources: false,
332 : );
333 1 : return _shared = TestHarness(
334 : spans: spanExporter,
335 : logs: logExporter,
336 : metrics: metricExporter,
337 : metricReader: reader,
338 : );
339 : }
|