Skip to content

Commit 29373c2

Browse files
committed
feat: Add serverpod_logging_cli bridge package
A cli_tools.Logger implementation (ServerpodCliLogger) backed by the serverpod_logging LogWriter architecture, plus StdOutLogWriter (a TextLogWriter that renders LogType-aware output via cli_tools' StdOutLogger).
1 parent 8f7a6f2 commit 29373c2

12 files changed

Lines changed: 609 additions & 2 deletions

‎.github/workflows/ci.yml‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,14 @@ jobs:
1515
fail-fast: false
1616
matrix:
1717
dart: [3.6, 3.12]
18-
package: [cli_tools, config, isolated_object, serverpod_logging]
18+
package:
19+
[
20+
cli_tools,
21+
config,
22+
isolated_object,
23+
serverpod_logging,
24+
serverpod_logging_cli,
25+
]
1926
runs-on: ubuntu-latest
2027
defaults:
2128
run:
@@ -35,7 +42,14 @@ jobs:
3542
fail-fast: false
3643
matrix:
3744
dart: [3.6, 3.12]
38-
package: [cli_tools, config, isolated_object, serverpod_logging]
45+
package:
46+
[
47+
cli_tools,
48+
config,
49+
isolated_object,
50+
serverpod_logging,
51+
serverpod_logging_cli,
52+
]
3953
platform: [ubuntu-latest]
4054
include:
4155
- package: cli_tools
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
name: Publish serverpod_logging_cli package
2+
3+
on:
4+
push:
5+
tags:
6+
# Matches tags like serverpod_logging_cli-v1.2.3 and serverpod_logging_cli-v1.2.3-pre.1
7+
- 'serverpod_logging_cli-v[0-9]+.[0-9]+.[0-9]+'
8+
- 'serverpod_logging_cli-v[0-9]+.[0-9]+.[0-9]+-*'
9+
10+
jobs:
11+
publish:
12+
permissions:
13+
id-token: write
14+
uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1
15+
with:
16+
working-directory: packages/serverpod_logging_cli
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.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
BSD 3-Clause License
2+
3+
Copyright (c) 2024, Serverpod
4+
5+
Redistribution and use in source and binary forms, with or without
6+
modification, are permitted provided that the following conditions are met:
7+
8+
1. Redistributions of source code must retain the above copyright notice, this
9+
list of conditions and the following disclaimer.
10+
11+
2. Redistributions in binary form must reproduce the above copyright notice,
12+
this list of conditions and the following disclaimer in the documentation
13+
and/or other materials provided with the distribution.
14+
15+
3. Neither the name of the copyright holder nor the names of its
16+
contributors may be used to endorse or promote products derived from
17+
this software without specific prior written permission.
18+
19+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# serverpod_logging_cli
2+
3+
A [`cli_tools`](https://pub.dev/packages/cli_tools) `Logger` implementation
4+
backed by the [`serverpod_logging`](https://pub.dev/packages/serverpod_logging)
5+
`LogWriter` architecture. It renders scoped logs and progress to the terminal
6+
while letting output fan out to any number of backends (terminal, file,
7+
database, …) via `LogWriter` / `MultiLogWriter`.
8+
9+
## Usage
10+
11+
```dart
12+
import 'package:serverpod_logging_cli/serverpod_logging_cli.dart';
13+
14+
final logger = ServerpodCliLogger(StdOutLogWriter());
15+
16+
logger.info('Server starting');
17+
await logger.progress('Migrating', () async => true);
18+
19+
await logger.flush();
20+
```
21+
22+
`progressStream` is modelled as a parent operation with one nested
23+
sub-operation per stream event - each a real, independently-timed scope - so
24+
the structure stays meaningful for non-terminal writers (a database writer
25+
records the parent span plus a child span per event).
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
include: package:serverpod_lints/cli.yaml
2+
3+
analyzer:
4+
language:
5+
strict-raw-types: false
6+
errors:
7+
inference_failure_on_instance_creation: ignore
8+
inference_failure_on_function_invocation: ignore
9+
inference_failure_on_untyped_parameter: ignore
10+
prefer_final_parameters: ignore
11+
12+
linter:
13+
rules:
14+
prefer_relative_imports: true
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/// A cli_tools `Logger` bridge for the serverpod_logging architecture.
2+
///
3+
/// This package sits on top of `serverpod_logging` (the dependency-free core)
4+
/// and `cli_tools` (the CLI toolkit), wiring the two together. Keeping it in
5+
/// its own package means the logging core never takes on the cli_tools
6+
/// dependency, and the cli_tools toolkit never learns about serverpod.
7+
library;
8+
9+
export 'src/serverpod_cli_logger.dart';
10+
export 'src/std_out_log_writer.dart';
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import 'dart:async';
2+
import 'dart:io';
3+
4+
import 'package:cli_tools/cli_tools.dart' as cli;
5+
import 'package:serverpod_logging/serverpod_logging.dart';
6+
7+
import 'std_out_log_writer.dart';
8+
9+
/// A [cli.Logger] that delegates to a [Log].
10+
///
11+
/// This bridges the [cli.Logger] interface with the [LogWriter] architecture,
12+
/// allowing multi-backend logging (terminal, TUI, file, database, etc.) via
13+
/// [LogWriter] and [MultiLogWriter].
14+
///
15+
/// [cli.LogType] is preserved by stashing it in [LogEntry.metadata] under
16+
/// [logTypeKey], so writers like [StdOutLogWriter] can format accordingly.
17+
class ServerpodCliLogger extends cli.Logger {
18+
final Log _log;
19+
final LogWriter _writer;
20+
21+
ServerpodCliLogger(LogWriter writer, {LogLevel logLevel = LogLevel.info})
22+
: _writer = writer,
23+
_log = Log(writer, logLevel: logLevel),
24+
super(toCliLogLevel(logLevel));
25+
26+
/// Releases any resources held by the underlying writer.
27+
Future<void> close() async {
28+
await _log.close();
29+
await _writer.close();
30+
}
31+
32+
@override
33+
set logLevel(cli.LogLevel level) {
34+
super.logLevel = level;
35+
if (level == cli.LogLevel.nothing) return;
36+
_log.logLevel = _mapLogLevel(level);
37+
}
38+
39+
@override
40+
int? get wrapTextColumn => stdout.hasTerminal ? stdout.terminalColumns : null;
41+
42+
@override
43+
void debug(
44+
String message, {
45+
bool newParagraph = false,
46+
cli.LogType type = cli.TextLogType.normal,
47+
}) =>
48+
_call(LogLevel.debug, message, type: type);
49+
50+
@override
51+
void info(
52+
String message, {
53+
bool newParagraph = false,
54+
cli.LogType type = cli.TextLogType.normal,
55+
}) =>
56+
_call(LogLevel.info, message, type: type);
57+
58+
@override
59+
void warning(
60+
String message, {
61+
bool newParagraph = false,
62+
cli.LogType type = cli.TextLogType.normal,
63+
}) =>
64+
_call(LogLevel.warning, message, type: type);
65+
66+
@override
67+
void error(
68+
String message, {
69+
bool newParagraph = false,
70+
StackTrace? stackTrace,
71+
cli.LogType type = cli.TextLogType.normal,
72+
}) =>
73+
_call(LogLevel.error, message, stackTrace: stackTrace, type: type);
74+
75+
@override
76+
void log(
77+
String message,
78+
cli.LogLevel level, {
79+
bool newParagraph = false,
80+
cli.LogType type = cli.TextLogType.normal,
81+
}) {
82+
// `nothing` is the "off" sentinel, not a message level - suppress rather
83+
// than map it (mapping would throw on this otherwise-valid enum value).
84+
if (level == cli.LogLevel.nothing) return;
85+
_call(_mapLogLevel(level), message, type: type);
86+
}
87+
88+
@override
89+
void write(
90+
String message,
91+
cli.LogLevel logLevel, {
92+
bool newParagraph = false,
93+
bool newLine = true,
94+
}) {
95+
if (logLevel == cli.LogLevel.nothing) return;
96+
_call(_mapLogLevel(logLevel), message);
97+
}
98+
99+
@override
100+
Future<bool> progress(
101+
String message,
102+
Future<bool> Function() runner, {
103+
bool newParagraph = false,
104+
String? successMessage,
105+
}) {
106+
if (_silent) return runner();
107+
return _log.progress(message, runner);
108+
}
109+
110+
/// Renders [stream] as a parent operation with one nested sub-operation per
111+
/// event. Each sub-operation stays open until the next event arrives (or the
112+
/// stream ends), so its elapsed time is the gap between events. The parent
113+
/// closes when the stream completes.
114+
///
115+
/// Returns the last event. Rethrows a stream error (failing the open
116+
/// sub-operation and the parent). Throws [StateError] if the stream is empty.
117+
@override
118+
Future<T> progressStream<T>(
119+
String initialMessage,
120+
Stream<T> stream, {
121+
String Function(T)? toMessage,
122+
bool Function(T)? isSuccess,
123+
bool newParagraph = false,
124+
}) async {
125+
if (_silent) return _drain(stream);
126+
127+
T? last;
128+
return _log.progress<T>(
129+
initialMessage,
130+
() async {
131+
final events = StreamIterator(stream);
132+
try {
133+
if (!await events.moveNext()) {
134+
throw StateError(_noEventsMessage);
135+
}
136+
while (true) {
137+
final event = events.current;
138+
last = event;
139+
final label = toMessage?.call(event) ?? event.toString();
140+
// The sub-operation's lifetime is the wait for the next event.
141+
var hasMore = false;
142+
await _log.progress<bool>(label, () async {
143+
hasMore = await events.moveNext();
144+
return true;
145+
});
146+
if (!hasMore) break;
147+
}
148+
} finally {
149+
await events.cancel();
150+
}
151+
return last as T;
152+
},
153+
// Let Log own the success verdict
154+
isSuccess: isSuccess ?? (_) => true,
155+
);
156+
}
157+
158+
@override
159+
Future<void> flush() => _log.flush();
160+
161+
/// Consumes [stream] without rendering, honoring the same return/throw
162+
/// contract as [progressStream] (used when output is silenced).
163+
static Future<T> _drain<T>(Stream<T> stream) async {
164+
T? last;
165+
var hasEvent = false;
166+
await for (final event in stream) {
167+
hasEvent = true;
168+
last = event;
169+
}
170+
if (!hasEvent) throw StateError(_noEventsMessage);
171+
return last as T;
172+
}
173+
174+
static const _noEventsMessage = 'No events in stream';
175+
176+
void _call(
177+
LogLevel level,
178+
String message, {
179+
StackTrace? stackTrace,
180+
cli.LogType? type,
181+
}) {
182+
if (_silent) return;
183+
_log(
184+
level,
185+
() => LogEntry(
186+
time: DateTime.now(),
187+
level: level,
188+
message: message,
189+
scope: _log.currentScope,
190+
stackTrace: stackTrace,
191+
metadata: type != null ? {logTypeKey: type} : null,
192+
),
193+
);
194+
}
195+
196+
// serverpod_logging LogLevel has no "nothing" sentinel - its lowest-passing
197+
// level is fatal, and the filter check is strict-`<` so fatal still leaks
198+
// through. Progress / scope events bypass logLevel entirely. So when the
199+
// caller sets cli.LogLevel.nothing we gate at the bridge instead of mapping
200+
// through to _log.logLevel.
201+
bool get _silent => super.logLevel == cli.LogLevel.nothing;
202+
203+
static LogLevel _mapLogLevel(cli.LogLevel level) => switch (level) {
204+
cli.LogLevel.debug => LogLevel.debug,
205+
cli.LogLevel.info => LogLevel.info,
206+
cli.LogLevel.warning => LogLevel.warning,
207+
cli.LogLevel.error => LogLevel.error,
208+
// The setter early-returns before reaching here, so this branch only
209+
// fires from log() / write() being called with nothing as a message
210+
// level - which is a programmer error worth surfacing.
211+
cli.LogLevel.nothing => throw ArgumentError.value(
212+
level,
213+
'level',
214+
'cli.LogLevel.nothing is a filter sentinel; it cannot be used as a '
215+
'message level',
216+
),
217+
};
218+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import 'package:cli_tools/cli_tools.dart' as cli;
2+
import 'package:serverpod_logging/serverpod_logging.dart';
3+
4+
/// Metadata key used to pass [cli.LogType] through [LogEntry.metadata].
5+
const logTypeKey = 'serverpod:logType';
6+
7+
/// Maps a [LogLevel] to the [cli.LogLevel].
8+
cli.LogLevel toCliLogLevel(LogLevel level) => switch (level) {
9+
LogLevel.debug => cli.LogLevel.debug,
10+
LogLevel.info => cli.LogLevel.info,
11+
LogLevel.warning => cli.LogLevel.warning,
12+
LogLevel.error || LogLevel.fatal => cli.LogLevel.error,
13+
};
14+
15+
/// A [TextLogWriter] that renders log lines through a [cli.StdOutLogger],
16+
/// adding [cli.LogType]-aware formatting, ie. bullets, headers, boxes, Windows
17+
/// emoji replacements, etc.
18+
class StdOutLogWriter extends TextLogWriter {
19+
final cli.StdOutLogger _logger;
20+
21+
StdOutLogWriter({Map<String, String>? replacements})
22+
: _logger = cli.StdOutLogger(
23+
// Accept all levels - filtering is done by Log, not the writer.
24+
cli.LogLevel.debug,
25+
replacements: replacements,
26+
// Match TextLogWriter: errors and above go to stderr.
27+
logToStderrLevelThreshold: cli.LogLevel.error,
28+
);
29+
30+
@override
31+
void writeLogLine(LogEntry entry) {
32+
final type =
33+
entry.metadata?[logTypeKey] as cli.LogType? ?? cli.TextLogType.normal;
34+
if (entry.level == LogLevel.error || entry.level == LogLevel.fatal) {
35+
_logger.error(entry.message, stackTrace: entry.stackTrace, type: type);
36+
} else {
37+
_logger.log(entry.message, toCliLogLevel(entry.level), type: type);
38+
}
39+
}
40+
}

0 commit comments

Comments
 (0)