Skip to content

Commit 601b54a

Browse files
passsyclaude
andauthored
Add lambda-based delegated interceptor and writer interfaces (#19)
* Add delegated implementations for interceptor, writer, and formatter Add three new classes that allow using lambdas instead of defining full classes when implementing the core interfaces: - DelegatedChirpInterceptor: Wraps (LogRecord) => LogRecord? function - DelegatedChirpWriter: Wraps (LogRecord) => void function - DelegatedConsoleMessageFormatter: Wraps (LogRecord, ConsoleMessageBuffer) => void function Each class: - Is const constructible for compile-time constants - Supports the requiresCallerInfo parameter for stack trace capture - Includes comprehensive documentation with usage examples - Has full test coverage (59 tests total for the new classes) This enables simpler inline usage patterns: // Before: Create a class class LevelFilter extends ChirpInterceptor { @OverRide LogRecord? intercept(LogRecord r) => r.level >= ChirpLogLevel.warning ? r : null; } // After: Use a lambda final filter = DelegatedChirpInterceptor( (r) => r.level >= ChirpLogLevel.warning ? r : null, ); * Add creation site capture for debugging delegated classes All delegated classes now automatically capture the stack trace at construction time, making them easier to identify during debugging: print(writer); // DelegatedChirpWriter(my_service.dart:42) Features: - creationSite property returns StackFrameInfo with file, line, method - toString() includes the creation location (file:line format) - captureCreationSite: false parameter to disable (saves memory/CPU) - Consistent API across all three delegated classes This addresses the debugging concern where DelegatedChirpWriter showed a generic type name instead of meaningful context like named classes do. * Simplify debugging API: expose StackTrace instead of StackFrameInfo Change the public API to expose only standard Dart types: - Rename creationSite to creationStackTrace (returns StackTrace?) - Keep internal use of getCallerInfo for toString() formatting - StackFrameInfo is no longer part of the public interface This keeps the debugging functionality (toString shows file:line) while not exposing internal types in the public API. * Capture creation stack trace only in debug mode Use a shared debugCaptureStackTrace() function that only captures stack traces when assertions are enabled, avoiding the overhead in release builds. * Remove InterceptorFunction typedef Use inline function type instead of typedef for cleaner API. * Remove FormatterFunction typedef Use inline function type instead of typedef for cleaner API. * Remove WriterFunction typedef Use inline function type instead of typedef for cleaner API. * Simplify delegated class tests Tests now only verify delegation behavior, not base class functionality which is already tested elsewhere. * Fix analyzer warning: remove redundant argument * Fix formatting * Override puro_sidekick_plugin to handle rate limits * Enable verbose logging for puro sidekick plugin in CI * Add puro config to root pubspec to avoid ls-versions call * Update puro_sidekick_plugin to latest handle-rate-limits commit Now includes disk caching for puro version responses to prevent hitting GitHub API rate limits. * Update puro_sidekick_plugin to latest commit * Update puro_sidekick_plugin to latest commit * Add PURO_LOG_LEVEL=4 for debugging CI puro issue * Use official release * Update cli deps * dartfmt * Remove debug logging --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0584138 commit 601b54a

8 files changed

Lines changed: 456 additions & 0 deletions

packages/chirp/lib/chirp.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export 'package:chirp/src/core/chirp_logger.dart' show ChirpLogger;
3232
export 'package:chirp/src/core/chirp_root.dart'
3333
show Chirp, ChirpInstanceLogger, ChirpLoggerConsoleWriterExt, LogRecordExt;
3434
export 'package:chirp/src/core/chirp_writer.dart' show ChirpWriter;
35+
export 'package:chirp/src/core/delegated_interceptor.dart'
36+
show DelegatedChirpInterceptor;
37+
export 'package:chirp/src/core/delegated_writer.dart' show DelegatedChirpWriter;
3538
export 'package:chirp/src/core/format_option.dart' show FormatOptions;
3639
export 'package:chirp/src/core/log_level.dart' show ChirpLogLevel;
3740
export 'package:chirp/src/core/log_record.dart' show LogRecord;
@@ -60,5 +63,7 @@ export 'package:chirp/src/writers/console_writer.dart'
6063
PrintConsoleWriter,
6164
splitIntoChunks,
6265
stripAnsiCodes;
66+
export 'package:chirp/src/writers/delegated_formatter.dart'
67+
show DelegatedConsoleMessageFormatter;
6368
export 'package:chirp/src/writers/developer_log_console_writer.dart'
6469
show DeveloperLogConsoleWriter;
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import 'package:chirp/src/core/chirp_interceptor.dart';
2+
import 'package:chirp/src/core/log_record.dart';
3+
import 'package:chirp/src/utils/stack_trace_util.dart';
4+
5+
/// {@template chirp.DelegatedChirpInterceptor}
6+
/// A [ChirpInterceptor] implementation that delegates to a function.
7+
///
8+
/// This allows creating interceptors inline without defining a class:
9+
///
10+
/// ```dart
11+
/// // Filter logs by level
12+
/// final levelFilter = DelegatedChirpInterceptor(
13+
/// (record) => record.level >= ChirpLogLevel.warning ? record : null,
14+
/// );
15+
///
16+
/// // Redact sensitive data
17+
/// final redactor = DelegatedChirpInterceptor((record) {
18+
/// final message = record.message.toString();
19+
/// if (message.contains('password')) {
20+
/// return record.copyWith(
21+
/// message: message.replaceAll(RegExp(r'password=\S+'), 'password=***'),
22+
/// );
23+
/// }
24+
/// return record;
25+
/// });
26+
///
27+
/// // Add context from zone
28+
/// final contextEnricher = DelegatedChirpInterceptor((record) {
29+
/// final requestId = Zone.current['requestId'];
30+
/// if (requestId != null) {
31+
/// return record.copyWith(data: {...record.data, 'requestId': requestId});
32+
/// }
33+
/// return record;
34+
/// });
35+
/// ```
36+
///
37+
/// ## Debugging
38+
///
39+
/// By default, the creation site is captured for debugging. This helps
40+
/// identify which delegated interceptor is which when inspecting in a debugger:
41+
///
42+
/// ```dart
43+
/// print(interceptor); // DelegatedChirpInterceptor(my_service.dart:42)
44+
/// ```
45+
///
46+
/// For more complex interceptors with state or configuration, consider
47+
/// extending [ChirpInterceptor] directly.
48+
/// {@endtemplate}
49+
class DelegatedChirpInterceptor extends ChirpInterceptor {
50+
/// The function that intercepts log records.
51+
final LogRecord? Function(LogRecord record) _intercept;
52+
53+
final bool _requiresCallerInfo;
54+
55+
/// Stack trace captured at construction time, for debugging.
56+
///
57+
/// Only captured when assertions are enabled (debug mode).
58+
final StackTrace? creationStackTrace;
59+
60+
/// {@macro chirp.DelegatedChirpInterceptor}
61+
///
62+
/// The [intercept] function receives a [LogRecord] and should return:
63+
/// - The record unchanged to pass through
64+
/// - A modified copy (via [LogRecord.copyWith]) to transform
65+
/// - `null` to reject the record and prevent it from being written
66+
///
67+
/// Set [requiresCallerInfo] to `true` if your interceptor needs access to
68+
/// caller information (file, line, class, method). This triggers stack trace
69+
/// capture which has a performance cost.
70+
DelegatedChirpInterceptor(
71+
LogRecord? Function(LogRecord record) intercept, {
72+
bool requiresCallerInfo = false,
73+
}) : _intercept = intercept,
74+
_requiresCallerInfo = requiresCallerInfo,
75+
creationStackTrace = debugCaptureStackTrace();
76+
77+
@override
78+
bool get requiresCallerInfo => _requiresCallerInfo;
79+
80+
@override
81+
LogRecord? intercept(LogRecord record) => _intercept(record);
82+
83+
@override
84+
String toString() {
85+
final stackTrace = creationStackTrace;
86+
if (stackTrace != null) {
87+
final info = getCallerInfo(stackTrace);
88+
if (info != null) {
89+
return 'DelegatedChirpInterceptor(${info.callerLocation})';
90+
}
91+
}
92+
return 'DelegatedChirpInterceptor';
93+
}
94+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import 'package:chirp/src/core/chirp_writer.dart';
2+
import 'package:chirp/src/core/log_record.dart';
3+
import 'package:chirp/src/utils/stack_trace_util.dart';
4+
5+
/// {@template chirp.DelegatedChirpWriter}
6+
/// A [ChirpWriter] implementation that delegates to a function.
7+
///
8+
/// This allows creating writers inline without defining a class:
9+
///
10+
/// ```dart
11+
/// // Simple writer that collects logs
12+
/// final logs = <LogRecord>[];
13+
/// final collector = DelegatedChirpWriter((record) => logs.add(record));
14+
///
15+
/// // Writer that sends to external service
16+
/// final analyticsWriter = DelegatedChirpWriter((record) {
17+
/// if (record.level >= ChirpLogLevel.error) {
18+
/// analytics.logError(
19+
/// message: record.message.toString(),
20+
/// error: record.error,
21+
/// stackTrace: record.stackTrace,
22+
/// );
23+
/// }
24+
/// });
25+
///
26+
/// // Writer that appends to a file
27+
/// final fileWriter = DelegatedChirpWriter((record) {
28+
/// final line = '${record.timestamp.toIso8601String()} '
29+
/// '[${record.level.name}] ${record.message}\n';
30+
/// logFile.writeAsStringSync(line, mode: FileMode.append);
31+
/// });
32+
///
33+
/// // Use with a logger
34+
/// final logger = ChirpLogger(name: 'MyLogger')
35+
/// .addWriter(collector)
36+
/// .addWriter(analyticsWriter);
37+
/// ```
38+
///
39+
/// ## Interceptors and Filtering
40+
///
41+
/// [DelegatedChirpWriter] inherits interceptor and minimum log level support
42+
/// from [ChirpWriter]:
43+
///
44+
/// ```dart
45+
/// final writer = DelegatedChirpWriter((record) => print(record.message))
46+
/// .setMinLogLevel(ChirpLogLevel.warning)
47+
/// .addInterceptor(myInterceptor);
48+
/// ```
49+
///
50+
/// ## Debugging
51+
///
52+
/// By default, the creation site is captured for debugging. This helps
53+
/// identify which delegated writer is which when inspecting in a debugger:
54+
///
55+
/// ```dart
56+
/// print(writer); // DelegatedChirpWriter(my_service.dart:42)
57+
/// ```
58+
///
59+
/// For more complex writers with state, configuration, or cleanup logic,
60+
/// consider extending [ChirpWriter] directly.
61+
/// {@endtemplate}
62+
class DelegatedChirpWriter extends ChirpWriter {
63+
/// The function that writes log records.
64+
final void Function(LogRecord record) _write;
65+
66+
final bool _requiresCallerInfo;
67+
68+
/// Stack trace captured at construction time, for debugging.
69+
///
70+
/// Only captured when assertions are enabled (debug mode).
71+
final StackTrace? creationStackTrace;
72+
73+
/// {@macro chirp.DelegatedChirpWriter}
74+
///
75+
/// The [write] function receives a [LogRecord] and should output it to the
76+
/// desired destination (console, file, network, monitoring service, etc.).
77+
///
78+
/// Set [requiresCallerInfo] to `true` if your writer needs access to caller
79+
/// information (file, line, class, method). This triggers stack trace
80+
/// capture which has a performance cost.
81+
///
82+
/// **Performance note**: The [write] function is called synchronously for
83+
/// each log event. For slow operations (network, disk), consider buffering
84+
/// or handling async operations within your function.
85+
DelegatedChirpWriter(
86+
void Function(LogRecord record) write, {
87+
bool requiresCallerInfo = false,
88+
}) : _write = write,
89+
_requiresCallerInfo = requiresCallerInfo,
90+
creationStackTrace = debugCaptureStackTrace();
91+
92+
@override
93+
bool get requiresCallerInfo => _requiresCallerInfo;
94+
95+
@override
96+
void write(LogRecord record) => _write(record);
97+
98+
@override
99+
String toString() {
100+
final stackTrace = creationStackTrace;
101+
if (stackTrace != null) {
102+
final info = getCallerInfo(stackTrace);
103+
if (info != null) {
104+
return 'DelegatedChirpWriter(${info.callerLocation})';
105+
}
106+
}
107+
return 'DelegatedChirpWriter';
108+
}
109+
}

packages/chirp/lib/src/utils/stack_trace_util.dart

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
/// Captures stack trace only when assertions are enabled (debug mode).
2+
///
3+
/// Returns `null` in release mode to avoid the performance overhead of
4+
/// capturing stack traces in production.
5+
///
6+
/// This is useful for debugging delegated implementations where the
7+
/// creation site helps identify which instance is which.
8+
StackTrace? debugCaptureStackTrace() {
9+
StackTrace? result;
10+
assert(() {
11+
result = StackTrace.current;
12+
return true;
13+
}());
14+
return result;
15+
}
16+
117
/// Information extracted from a stack frame
218
class StackFrameInfo {
319
/// The raw caller method (e.g., `UserService.processUser.<anonymous closure>`)
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import 'package:chirp/src/core/log_record.dart';
2+
import 'package:chirp/src/utils/stack_trace_util.dart';
3+
import 'package:chirp/src/writers/console_writer.dart';
4+
5+
/// {@template chirp.DelegatedConsoleMessageFormatter}
6+
/// A [ConsoleMessageFormatter] implementation that delegates to a function.
7+
///
8+
/// This allows creating formatters inline without defining a class:
9+
///
10+
/// ```dart
11+
/// // Simple formatter
12+
/// final simpleFormatter = DelegatedConsoleMessageFormatter((record, buffer) {
13+
/// buffer.write('[${record.level.name}] ${record.message}');
14+
/// });
15+
///
16+
/// // Formatter with colors
17+
/// final colorFormatter = DelegatedConsoleMessageFormatter((record, buffer) {
18+
/// buffer.pushStyle(foreground: Ansi256.cyan_6);
19+
/// buffer.write(record.timestamp.toIso8601String());
20+
/// buffer.popStyle();
21+
/// buffer.write(' ');
22+
/// buffer.pushStyle(foreground: Ansi256.yellow_3, bold: true);
23+
/// buffer.write('[${record.level.name.toUpperCase()}]');
24+
/// buffer.popStyle();
25+
/// buffer.write(' ${record.message}');
26+
/// });
27+
///
28+
/// // Formatter that includes structured data
29+
/// final dataFormatter = DelegatedConsoleMessageFormatter((record, buffer) {
30+
/// buffer.write('${record.message}');
31+
/// if (record.data.isNotEmpty) {
32+
/// buffer.write(' | ');
33+
/// buffer.pushStyle(dim: true);
34+
/// buffer.write(record.data.entries
35+
/// .map((e) => '${e.key}=${e.value}')
36+
/// .join(', '));
37+
/// buffer.popStyle();
38+
/// }
39+
/// });
40+
///
41+
/// // Use with PrintConsoleWriter
42+
/// final writer = PrintConsoleWriter(formatter: simpleFormatter);
43+
/// ```
44+
///
45+
/// ## Using the Buffer
46+
///
47+
/// The [ConsoleMessageBuffer] provides methods for building styled output:
48+
/// - [ConsoleMessageBuffer.write] - Write text with optional inline colors
49+
/// - [ConsoleMessageBuffer.pushStyle] / [ConsoleMessageBuffer.popStyle] - Manage nested styles
50+
/// - [ConsoleMessageBuffer.capabilities] - Query terminal capabilities
51+
///
52+
/// ## Debugging
53+
///
54+
/// By default, the creation site is captured for debugging. This helps
55+
/// identify which delegated formatter is which when inspecting in a debugger:
56+
///
57+
/// ```dart
58+
/// print(formatter); // DelegatedConsoleMessageFormatter(my_service.dart:42)
59+
/// ```
60+
///
61+
/// For more complex formatters with configuration or state, consider extending
62+
/// [ConsoleMessageFormatter] directly.
63+
/// {@endtemplate}
64+
class DelegatedConsoleMessageFormatter extends ConsoleMessageFormatter {
65+
/// The function that formats log records.
66+
final void Function(LogRecord record, ConsoleMessageBuffer buffer) _format;
67+
68+
final bool _requiresCallerInfo;
69+
70+
/// Stack trace captured at construction time, for debugging.
71+
///
72+
/// Only captured when assertions are enabled (debug mode).
73+
final StackTrace? creationStackTrace;
74+
75+
/// {@macro chirp.DelegatedConsoleMessageFormatter}
76+
///
77+
/// The [format] function receives a [LogRecord] and a [ConsoleMessageBuffer].
78+
/// Write the formatted output to the buffer using its methods.
79+
///
80+
/// Set [requiresCallerInfo] to `true` if your formatter needs access to
81+
/// caller information (file, line, class, method). This triggers stack trace
82+
/// capture which has a performance cost.
83+
DelegatedConsoleMessageFormatter(
84+
void Function(LogRecord record, ConsoleMessageBuffer buffer) format, {
85+
bool requiresCallerInfo = false,
86+
}) : _format = format,
87+
_requiresCallerInfo = requiresCallerInfo,
88+
creationStackTrace = debugCaptureStackTrace();
89+
90+
@override
91+
bool get requiresCallerInfo => _requiresCallerInfo;
92+
93+
@override
94+
void format(LogRecord record, ConsoleMessageBuffer buffer) =>
95+
_format(record, buffer);
96+
97+
@override
98+
String toString() {
99+
final stackTrace = creationStackTrace;
100+
if (stackTrace != null) {
101+
final info = getCallerInfo(stackTrace);
102+
if (info != null) {
103+
return 'DelegatedConsoleMessageFormatter(${info.callerLocation})';
104+
}
105+
}
106+
return 'DelegatedConsoleMessageFormatter';
107+
}
108+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import 'package:chirp/chirp.dart';
2+
import 'package:test/test.dart';
3+
4+
void main() {
5+
group('DelegatedConsoleMessageFormatter', () {
6+
test('calls the provided function with record and buffer', () {
7+
LogRecord? receivedRecord;
8+
ConsoleMessageBuffer? receivedBuffer;
9+
10+
final formatter = DelegatedConsoleMessageFormatter((record, buffer) {
11+
receivedRecord = record;
12+
receivedBuffer = buffer;
13+
});
14+
15+
final record = LogRecord(message: 'Test', timestamp: DateTime.now());
16+
final buffer = ConsoleMessageBuffer(
17+
capabilities: const TerminalCapabilities(),
18+
);
19+
20+
formatter.format(record, buffer);
21+
22+
expect(receivedRecord, same(record));
23+
expect(receivedBuffer, same(buffer));
24+
});
25+
26+
test('requiresCallerInfo defaults to false', () {
27+
final formatter = DelegatedConsoleMessageFormatter((record, buffer) {});
28+
expect(formatter.requiresCallerInfo, isFalse);
29+
});
30+
31+
test('requiresCallerInfo can be set to true', () {
32+
final formatter = DelegatedConsoleMessageFormatter(
33+
(record, buffer) {},
34+
requiresCallerInfo: true,
35+
);
36+
expect(formatter.requiresCallerInfo, isTrue);
37+
});
38+
39+
test('toString includes creation site in debug mode', () {
40+
final formatter = DelegatedConsoleMessageFormatter((record, buffer) {});
41+
expect(formatter.toString(), contains('delegated_formatter_test'));
42+
});
43+
});
44+
}

0 commit comments

Comments
 (0)