diff --git a/pkgs/google_cloud/CHANGELOG.md b/pkgs/google_cloud/CHANGELOG.md index 312d7eb1..ccb3dd32 100644 --- a/pkgs/google_cloud/CHANGELOG.md +++ b/pkgs/google_cloud/CHANGELOG.md @@ -1,3 +1,20 @@ +## 0.4.0-wip + +### BREAKING CHANGES + +- Renamed `RequestLogger` to `CloudLogger` and moved it to + `package:google_cloud/general.dart`. + +### New Features + +- `CloudLogger` is no longer abstract and has a default implementation that + prints to stdout. +- Added `payload`, `labels`, and `stackTrace` named parameters to + `CloudLogger` functions as well as `structuredLogEntry`. +- Hardened structured log JSON serialization with automatic fallback mechanisms + to safely handle native `toJson()` implementations and circular references + without failing. + ## 0.3.1 - Fix a bug where `projectIdFromGcloudConfig()` used the incorrect gcloud shell diff --git a/pkgs/google_cloud/lib/general.dart b/pkgs/google_cloud/lib/general.dart index 6b28f8fd..b09fa5f8 100644 --- a/pkgs/google_cloud/lib/general.dart +++ b/pkgs/google_cloud/lib/general.dart @@ -21,8 +21,9 @@ /// {@canonicalFor gcp_project.MetadataServerException} /// {@canonicalFor gcp_project.projectIdFromMetadataServer} /// {@canonicalFor gcp_project.serviceAccountEmailFromMetadataServer} -/// {@canonicalFor logging.LogSeverity} -/// {@canonicalFor logging.structuredLogEntry} +/// {@canonicalFor logger.LogSeverity} +/// {@canonicalFor logger.CloudLogger} +/// {@canonicalFor structured_logging.structuredLogEntry} /// {@canonicalFor metadata.gceMetadataHost} /// {@canonicalFor metadata.gceMetadataUrl} library; @@ -38,5 +39,6 @@ export 'src/gcp_project.dart' projectIdFromGcloudConfig, projectIdFromMetadataServer, serviceAccountEmailFromMetadataServer; -export 'src/logging.dart' show LogSeverity, structuredLogEntry; +export 'src/logger.dart' show CloudLogger, LogSeverity; export 'src/metadata.dart' show gceMetadataHost, gceMetadataUrl; +export 'src/structured_logging.dart' show structuredLogEntry; diff --git a/pkgs/google_cloud/lib/http_serving.dart b/pkgs/google_cloud/lib/http_serving.dart index ce101f01..b1d4facf 100644 --- a/pkgs/google_cloud/lib/http_serving.dart +++ b/pkgs/google_cloud/lib/http_serving.dart @@ -40,7 +40,6 @@ /// /// {@canonicalFor bad_configuration_exception.BadConfigurationException} /// {@canonicalFor bad_request_exception.BadRequestException} -/// {@canonicalFor http_logging.RequestLogger} /// {@canonicalFor http_logging.badRequestMiddleware} /// {@canonicalFor http_logging.cloudLoggingMiddleware} /// {@canonicalFor http_logging.createLoggingMiddleware} @@ -50,13 +49,12 @@ /// {@canonicalFor terminate.waitForTerminate} library; +export 'src/logger.dart' show CloudLogger, LogSeverity; export 'src/serving/bad_configuration_exception.dart' show BadConfigurationException; export 'src/serving/bad_request_exception.dart' show BadRequestException; export 'src/serving/http_logging.dart' show - LogSeverity, - RequestLogger, badRequestMiddleware, cloudLoggingMiddleware, createLoggingMiddleware, diff --git a/pkgs/google_cloud/lib/src/logger.dart b/pkgs/google_cloud/lib/src/logger.dart new file mode 100644 index 00000000..957002a4 --- /dev/null +++ b/pkgs/google_cloud/lib/src/logger.dart @@ -0,0 +1,254 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// @docImport 'structured_logging.dart'; +library; + +import 'package:meta/meta.dart'; +import 'package:stack_trace/stack_trace.dart'; + +/// See https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity +enum LogSeverity implements Comparable { + defaultSeverity._(0, 'DEFAULT'), + debug._(100, 'DEBUG'), + info._(200, 'INFO'), + notice._(300, 'NOTICE'), + warning._(400, 'WARNING'), + error._(500, 'ERROR'), + critical._(600, 'CRITICAL'), + alert._(700, 'ALERT'), + emergency._(800, 'EMERGENCY'); + + final int value; + final String name; + + const LogSeverity._(this.value, this.name); + + @override + int compareTo(LogSeverity other) => value.compareTo(other.value); + + bool operator <(LogSeverity other) => value < other.value; + + bool operator <=(LogSeverity other) => value <= other.value; + + bool operator >(LogSeverity other) => value > other.value; + + bool operator >=(LogSeverity other) => value >= other.value; + + @override + String toString() => 'LogSeverity $name ($value)'; + + String toJson() => name; +} + +/// Allows logging at a specified severity. +/// +/// Compatible with the +/// [log severities](https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity) +/// supported by Google Cloud. +abstract base class CloudLogger { + /// Const constructor for subclasses. + const CloudLogger(); + + /// The default logger. + /// + /// This logger prints messages to the console. + /// + /// The output format is: + /// `[SEVERITY_NAME: ][ payload][ labels][\nstack_trace]` + /// + /// The `SEVERITY_NAME: ` prefix is omitted when the severity is + /// [LogSeverity.defaultSeverity]. + const factory CloudLogger.defaultLogger() = _DefaultLogger; + + /// Logs [message] at the given [severity]. + /// + /// {@template google_cloud.CloudLogger.log_params} + /// Details on how the parameters are handled can be found depending on the + /// implementation. + /// + /// See [CloudLogger.defaultLogger] and [structuredLogEntry] for more + /// information. + /// {@endtemplate} + void log( + Object message, + LogSeverity severity, { + Map? payload, + Map? labels, + StackTrace? stackTrace, + }); + + /// Logs [message] at [LogSeverity.debug] severity. + /// + /// {@macro google_cloud.CloudLogger.log_params} + void debug( + Object message, { + Map? payload, + Map? labels, + StackTrace? stackTrace, + }) => log( + message, + LogSeverity.debug, + payload: payload, + labels: labels, + stackTrace: stackTrace, + ); + + /// Logs [message] at [LogSeverity.info] severity. + /// + /// {@macro google_cloud.CloudLogger.log_params} + void info( + Object message, { + Map? payload, + Map? labels, + StackTrace? stackTrace, + }) => log( + message, + LogSeverity.info, + payload: payload, + labels: labels, + stackTrace: stackTrace, + ); + + /// Logs [message] at [LogSeverity.notice] severity. + /// + /// {@macro google_cloud.CloudLogger.log_params} + void notice( + Object message, { + Map? payload, + Map? labels, + StackTrace? stackTrace, + }) => log( + message, + LogSeverity.notice, + payload: payload, + labels: labels, + stackTrace: stackTrace, + ); + + /// Logs [message] at [LogSeverity.warning] severity. + /// + /// {@macro google_cloud.CloudLogger.log_params} + void warning( + Object message, { + Map? payload, + Map? labels, + StackTrace? stackTrace, + }) => log( + message, + LogSeverity.warning, + payload: payload, + labels: labels, + stackTrace: stackTrace, + ); + + /// Logs [message] at [LogSeverity.error] severity. + /// + /// {@macro google_cloud.CloudLogger.log_params} + void error( + Object message, { + Map? payload, + Map? labels, + StackTrace? stackTrace, + }) => log( + message, + LogSeverity.error, + payload: payload, + labels: labels, + stackTrace: stackTrace, + ); + + /// Logs [message] at [LogSeverity.critical] severity. + /// + /// {@macro google_cloud.CloudLogger.log_params} + void critical( + Object message, { + Map? payload, + Map? labels, + StackTrace? stackTrace, + }) => log( + message, + LogSeverity.critical, + payload: payload, + labels: labels, + stackTrace: stackTrace, + ); + + /// Logs [message] at [LogSeverity.alert] severity. + /// + /// {@macro google_cloud.CloudLogger.log_params} + void alert( + Object message, { + Map? payload, + Map? labels, + StackTrace? stackTrace, + }) => log( + message, + LogSeverity.alert, + payload: payload, + labels: labels, + stackTrace: stackTrace, + ); + + /// Logs [message] at [LogSeverity.emergency] severity. + /// + /// {@macro google_cloud.CloudLogger.log_params} + void emergency( + Object message, { + Map? payload, + Map? labels, + StackTrace? stackTrace, + }) => log( + message, + LogSeverity.emergency, + payload: payload, + labels: labels, + stackTrace: stackTrace, + ); +} + +/// The implementation for [CloudLogger.defaultLogger]. +final class _DefaultLogger extends CloudLogger { + /// Const constructor. + const _DefaultLogger(); + + @override + void log( + Object message, + LogSeverity severity, { + Map? payload, + Map? labels, + StackTrace? stackTrace, + }) { + final payloadStr = payload != null && payload.isNotEmpty ? ' $payload' : ''; + final labelsStr = labels != null && labels.isNotEmpty ? ' $labels' : ''; + final traceStr = stackTrace != null + ? '\n${formatStackTrace(stackTrace)}' + : ''; + if (severity == LogSeverity.defaultSeverity) { + print('$message$payloadStr$labelsStr$traceStr'); + } else { + print('${severity.name}: $message$payloadStr$labelsStr$traceStr'); + } + } +} + +@internal +bool frameFolder(Frame frame) => + frame.isCore || frame.package == 'google_cloud'; + +@internal +Chain formatStackTrace(StackTrace? stackTrace) => + (stackTrace == null ? Chain.current() : Chain.forTrace(stackTrace)) + .foldFrames(frameFolder, terse: true); diff --git a/pkgs/google_cloud/lib/src/logging.dart b/pkgs/google_cloud/lib/src/logging.dart deleted file mode 100644 index d5ba5a06..00000000 --- a/pkgs/google_cloud/lib/src/logging.dart +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright 2021 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import 'dart:convert'; -import 'package:meta/meta.dart'; - -import 'package:stack_trace/stack_trace.dart'; - -/// See https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity -enum LogSeverity implements Comparable { - defaultSeverity._(0, 'DEFAULT'), - debug._(100, 'DEBUG'), - info._(200, 'INFO'), - notice._(300, 'NOTICE'), - warning._(400, 'WARNING'), - error._(500, 'ERROR'), - critical._(600, 'CRITICAL'), - alert._(700, 'ALERT'), - emergency._(800, 'EMERGENCY'); - - final int value; - final String name; - - const LogSeverity._(this.value, this.name); - - @override - int compareTo(LogSeverity other) => value.compareTo(other.value); - - bool operator <(LogSeverity other) => value < other.value; - - bool operator <=(LogSeverity other) => value <= other.value; - - bool operator >(LogSeverity other) => value > other.value; - - bool operator >=(LogSeverity other) => value >= other.value; - - @override - String toString() => 'LogSeverity $name ($value)'; - - String toJson() => name; -} - -/// Creates a JSON-encoded log entry that conforms with -/// [structured logs](https://cloud.google.com/functions/docs/monitoring/logging#writing_structured_logs). -/// -/// [message] is the log message. It SHOULD be JSON-encodable. If it is not, it -/// will be converted to a [String] and used as the log entry message. -String structuredLogEntry( - Object message, - LogSeverity severity, { - String? traceId, - StackTrace? stackTrace, -}) { - final stackFrame = _debugFrame(severity, stackTrace: stackTrace); - - // https://cloud.google.com/logging/docs/agent/logging/configuration#special-fields - Map createContent(Object innerMessage) => { - 'message': innerMessage, - 'severity': severity, - // 'logging.googleapis.com/labels': { } - 'logging.googleapis.com/trace': ?traceId, - if (stackFrame != null) - 'logging.googleapis.com/sourceLocation': _sourceLocation(stackFrame), - }; - - try { - return jsonEncode(createContent(message)); - } catch (e) { - return jsonEncode(createContent(message.toString())); - } -} - -/// Returns a [Map] representing the source location of the given [frame]. -/// -/// See https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#LogEntrySourceLocation -Map _sourceLocation(Frame frame) => { - // TODO: Will need to fix `package:` URIs to file paths when possible - // GoogleCloudPlatform/functions-framework-dart#40 - 'file': frame.uri.toString(), - if (frame.line != null) 'line': frame.line.toString(), - 'function': frame.member, -}; - -Frame? _debugFrame(LogSeverity severity, {StackTrace? stackTrace}) { - if (stackTrace == null) { - if (severity >= LogSeverity.warning) { - stackTrace = StackTrace.current; - } else { - return null; - } - } - - final chain = formatStackTrace(stackTrace); - final stackFrame = chain.traces - .expand((t) => t.frames) - .firstWhere( - (f) => !_frameFolder(f), - orElse: () => chain.traces.first.frames.first, - ); - - return stackFrame; -} - -@internal -Chain formatStackTrace(StackTrace? stackTrace) => - (stackTrace == null ? Chain.current() : Chain.forTrace(stackTrace)) - .foldFrames(_frameFolder, terse: true); - -bool _frameFolder(Frame frame) => - frame.isCore || frame.package == 'google_cloud'; diff --git a/pkgs/google_cloud/lib/src/serving/http_logging.dart b/pkgs/google_cloud/lib/src/serving/http_logging.dart index 77b26c20..0ee575b7 100644 --- a/pkgs/google_cloud/lib/src/serving/http_logging.dart +++ b/pkgs/google_cloud/lib/src/serving/http_logging.dart @@ -20,10 +20,11 @@ import 'package:io/ansi.dart'; import 'package:shelf/shelf.dart'; import '../constants.dart'; -import '../logging.dart'; +import '../logger.dart'; +import '../structured_logging.dart'; import 'bad_request_exception.dart'; -export '../logging.dart'; +export '../structured_logging.dart'; const _badRequestExceptionContextKey = 'google_cloud.bad_request_exception'; const _badStackTraceContextKey = 'google_cloud.bad_stack_trace'; @@ -183,96 +184,50 @@ Middleware cloudLoggingMiddleware(String projectId) { return hostedLoggingMiddleware; } -/// Returns the current [RequestLogger]. +/// Returns the current [CloudLogger]. /// -/// If called within a context configured with a [RequestLogger], the returned -/// [RequestLogger] will be used. +/// If called within a context configured with a [CloudLogger], the returned +/// [CloudLogger] will be used. /// -/// Otherwise, the returned [RequestLogger] will simply [print] log entries, +/// Otherwise, the returned [CloudLogger] will simply [print] log entries, /// with entries having a [LogSeverity] different than /// [LogSeverity.defaultSeverity] being prefixed as such. -RequestLogger get currentLogger => - Zone.current[_loggerKey] as RequestLogger? ?? const _DefaultLogger(); +CloudLogger get currentLogger => + Zone.current[_loggerKey] as CloudLogger? ?? + const CloudLogger.defaultLogger(); -/// Used to represent the [RequestLogger] in [Zone] values. +/// Used to represent the [CloudLogger] in [Zone] values. final _loggerKey = Object(); -/// Allows logging at a specified severity. -/// -/// Compatible with the -/// [log severities](https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity) -/// supported by Google Cloud. -abstract final class RequestLogger { - /// Const constructor for subclasses. - const RequestLogger(); - - /// Logs [message] at the given [severity]. - void log(Object message, LogSeverity severity); - - /// Logs [message] at [LogSeverity.debug] severity. - void debug(Object message) => log(message, LogSeverity.debug); - - /// Logs [message] at [LogSeverity.info] severity. - void info(Object message) => log(message, LogSeverity.info); - - /// Logs [message] at [LogSeverity.notice] severity. - void notice(Object message) => log(message, LogSeverity.notice); - - /// Logs [message] at [LogSeverity.warning] severity. - void warning(Object message) => log(message, LogSeverity.warning); - - /// Logs [message] at [LogSeverity.error] severity. - void error(Object message) => log(message, LogSeverity.error); - - /// Logs [message] at [LogSeverity.critical] severity. - void critical(Object message) => log(message, LogSeverity.critical); - - /// Logs [message] at [LogSeverity.alert] severity. - void alert(Object message) => log(message, LogSeverity.alert); - - /// Logs [message] at [LogSeverity.emergency] severity. - void emergency(Object message) => log(message, LogSeverity.emergency); -} - -/// A [RequestLogger] that prints messages normally. -/// -/// Any message that's not [LogSeverity.defaultSeverity] is prefixed by the -/// [LogSeverity] name. -final class _DefaultLogger extends RequestLogger { - /// Const constructor. - const _DefaultLogger(); - - @override - void log(Object message, LogSeverity severity) { - if (severity == LogSeverity.defaultSeverity) { - print(message); - } else { - print('${severity.name}: $message'); - } - } -} - -/// A [RequestLogger] that prints messages using Google Cloud structured +/// A [CloudLogger] that prints messages using Google Cloud structured /// logging. -final class _CloudLogger extends RequestLogger { - final Zone _zone; +final class _CloudLogger extends CloudLogger { + final Zone zone; final String? _traceId; - /// Creates a new [_CloudLogger] that prints structured logs to [_zone]. + /// Creates a new [_CloudLogger] that prints structured logs to [this.zone]. /// /// If [_traceId] is provided, it is included in the log entry. - _CloudLogger({Zone? zone, String? traceId}) - : _zone = zone ?? Zone.current, - _traceId = traceId; + _CloudLogger({required this.zone, String? traceId}) : _traceId = traceId; /// If [message] is a [Map], it is used as the log entry payload. Otherwise, - /// it is converted to a [String] and used as the log entry message. + /// it is passed directly to [structuredLogEntry], which handles + /// serialization. @override - void log(Object message, LogSeverity severity) => _zone.print( + void log( + Object message, + LogSeverity severity, { + Map? payload, + Map? labels, + StackTrace? stackTrace, + }) => zone.print( structuredLogEntry( - message is Map ? message : '$message', + message, severity, + payload: payload, + labels: labels, traceId: _traceId, + stackTrace: stackTrace, ), ); } diff --git a/pkgs/google_cloud/lib/src/structured_logging.dart b/pkgs/google_cloud/lib/src/structured_logging.dart new file mode 100644 index 00000000..ef407b99 --- /dev/null +++ b/pkgs/google_cloud/lib/src/structured_logging.dart @@ -0,0 +1,124 @@ +// Copyright 2021 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:convert'; + +import 'package:stack_trace/stack_trace.dart'; + +import 'logger.dart'; + +/// Creates a JSON-encoded log entry that conforms with +/// [structured logging](https://docs.cloud.google.com/logging/docs/structured-logging). +/// +/// [message] is the log message. If it is a [Map], it is treated as the base +/// [payload], merging with any keys explicitly provided in the [payload] +/// parameter (with the parameter taking precedence). If the resulting merged +/// map contains a `"message"` key, it will be extracted and used as the entry's +/// message. If [message] is an empty `String` (or omitted via Map merging), +/// the message field is omitted from the resulting JSON completely. +/// For all other types, [Object.toString] or `toJson()` will be called. +/// +/// [payload] is an optional map of additional fields to include in the log +/// entry. These fields will be merged into the JSON root. +/// +/// [traceId] is an optional trace ID to include in the log entry. +/// +/// [stackTrace] is an optional stack trace to include in the log entry. +String structuredLogEntry( + Object message, + LogSeverity severity, { + Map? payload, + Map? labels, + String? traceId, + StackTrace? stackTrace, +}) { + var actualMessage = message; + var actualPayload = payload; + + if (message is Map) { + actualPayload = { + for (final entry in message.entries) entry.key.toString(): entry.value, + ...?payload, + }; + if (actualPayload.containsKey('message')) { + actualMessage = actualPayload.remove('message') ?? ''; + } else { + actualMessage = ''; + } + } + + final stackFrame = _debugFrame(severity, stackTrace: stackTrace); + + // https://cloud.google.com/logging/docs/agent/logging/configuration#special-fields + String encode(Object innerMessage, Map? innerPayload) => + jsonEncode(toEncodable: _toEncodableFallback, { + ...?innerPayload, + if (innerMessage != '') 'message': innerMessage, + 'severity': severity, + if (labels != null && labels.isNotEmpty) + 'logging.googleapis.com/labels': labels, + if (stackTrace != null) 'stack_trace': formatStackTrace(stackTrace), + 'logging.googleapis.com/trace': ?traceId, + if (stackFrame != null) + 'logging.googleapis.com/sourceLocation': _sourceLocation(stackFrame), + }); + + try { + return encode(actualMessage, actualPayload); + // ignore: avoid_catching_errors + } on JsonUnsupportedObjectError catch (_) { + // Fallback if there are cyclic errors parsing `payload` or `actualMessage`. + // We omit the payload to guarantee a safe serialization. + return encode(actualMessage.toString(), null); + } +} + +Object? _toEncodableFallback(Object? nonEncodable) { + try { + return (nonEncodable as dynamic).toJson(); + } catch (_) { + return nonEncodable.toString(); + } +} + +/// Returns a [Map] representing the source location of the given [frame]. +/// +/// See https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#LogEntrySourceLocation +Map _sourceLocation(Frame frame) => { + // TODO: Will need to fix `package:` URIs to file paths when possible + // GoogleCloudPlatform/functions-framework-dart#40 + 'file': frame.uri.toString(), + if (frame.line != null) 'line': frame.line.toString(), + 'function': frame.member, +}; + +Frame? _debugFrame(LogSeverity severity, {StackTrace? stackTrace}) { + if (stackTrace == null) { + if (severity >= LogSeverity.warning) { + stackTrace = StackTrace.current; + } else { + return null; + } + } + + final chain = formatStackTrace(stackTrace); + final stackFrame = chain.traces + .expand((t) => t.frames) + .firstWhere( + (f) => !frameFolder(f), + orElse: () => chain.traces.first.frames.first, + ); + + return stackFrame; +} diff --git a/pkgs/google_cloud/pubspec.yaml b/pkgs/google_cloud/pubspec.yaml index d0319126..f1e91a01 100644 --- a/pkgs/google_cloud/pubspec.yaml +++ b/pkgs/google_cloud/pubspec.yaml @@ -1,7 +1,7 @@ name: google_cloud description: >- Utilities for running Dart code correctly on the Google Cloud Platform. -version: 0.3.1 +version: 0.4.0-wip repository: https://github.com/googleapis/google-cloud-dart/tree/main/pkgs/google_cloud resolution: workspace diff --git a/pkgs/google_cloud/test/logging_test.dart b/pkgs/google_cloud/test/logging_test.dart index a14db638..1d72f055 100644 --- a/pkgs/google_cloud/test/logging_test.dart +++ b/pkgs/google_cloud/test/logging_test.dart @@ -15,7 +15,6 @@ @TestOn('vm') library; -import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -29,8 +28,7 @@ void main() { test('simple message', () { final entry = structuredLogEntry('hello', LogSeverity.info); final map = jsonDecode(entry) as Map; - expect(map, containsPair('message', 'hello')); - expect(map, containsPair('severity', 'INFO')); + expect(map, {'message': 'hello', 'severity': 'INFO'}); }); test('message with traceId', () { @@ -40,25 +38,136 @@ void main() { traceId: 'trace-123', ); final map = jsonDecode(entry) as Map; - expect(map, containsPair('message', 'hello')); - expect(map, containsPair('severity', 'INFO')); - expect(map, containsPair('logging.googleapis.com/trace', 'trace-123')); + expect(map, { + 'message': 'hello', + 'severity': 'INFO', + 'logging.googleapis.com/trace': 'trace-123', + }); }); - test('json encodable message', () { + test('list message remains in message key', () { + final message = ['foo', 'bar']; + final entry = structuredLogEntry(message, LogSeverity.info); + final map = jsonDecode(entry) as Map; + expect(map, {'message': message, 'severity': 'INFO'}); + }); + + test('map message is merged into payload', () { final message = {'foo': 'bar', 'count': 42}; final entry = structuredLogEntry(message, LogSeverity.info); final map = jsonDecode(entry) as Map; - expect(map, containsPair('message', message)); - expect(map, containsPair('severity', 'INFO')); + expect(map, {'foo': 'bar', 'count': 42, 'severity': 'INFO'}); + }); + + test('map message with message key extracts message', () { + final message = {'foo': 'bar', 'message': 'my msg'}; + final entry = structuredLogEntry(message, LogSeverity.info); + final map = jsonDecode(entry) as Map; + expect(map, {'foo': 'bar', 'message': 'my msg', 'severity': 'INFO'}); + }); + + test('payload overrides map message', () { + final message = {'foo': 'bar', 'count': 42, 'message': 'original'}; + final entry = structuredLogEntry( + message, + LogSeverity.info, + payload: {'count': 99, 'env': 'prod', 'message': 'overridden'}, + ); + final map = jsonDecode(entry) as Map; + expect(map, { + 'foo': 'bar', + 'count': 99, + 'env': 'prod', + 'message': 'overridden', + 'severity': 'INFO', + }); }); test('non-encodable message is stringified', () { final message = _NonEncodable(); final entry = structuredLogEntry(message, LogSeverity.info); final map = jsonDecode(entry) as Map; - expect(map, containsPair('message', 'I am not encodable')); - expect(map, containsPair('severity', 'INFO')); + expect(map, {'message': 'I am not encodable', 'severity': 'INFO'}); + }); + + test('with payload', () { + final entry = structuredLogEntry( + 'hello', + LogSeverity.info, + payload: {'foo': 'bar', 'count': 42}, + ); + final map = jsonDecode(entry) as Map; + expect(map, { + 'foo': 'bar', + 'count': 42, + 'message': 'hello', + 'severity': 'INFO', + }); + }); + + test('with empty message', () { + final entry = structuredLogEntry( + '', + LogSeverity.info, + payload: {'foo': 'bar', 'count': 42}, + ); + final map = jsonDecode(entry) as Map; + expect(map, {'foo': 'bar', 'count': 42, 'severity': 'INFO'}); + }); + + test('with labels', () { + final entry = structuredLogEntry( + 'hello', + LogSeverity.info, + labels: {'env': 'prod', 'region': 'us-central1'}, + ); + final map = jsonDecode(entry) as Map; + expect(map, { + 'message': 'hello', + 'severity': 'INFO', + 'logging.googleapis.com/labels': { + 'env': 'prod', + 'region': 'us-central1', + }, + }); + }); + + test('payload does not override core fields', () { + final entry = structuredLogEntry( + 'hello', + LogSeverity.info, + payload: {'message': 'overridden', 'severity': 'CRITICAL'}, + ); + final map = jsonDecode(entry) as Map; + expect(map, {'message': 'hello', 'severity': 'INFO'}); + }); + + test('non-encodable payload is stringified', () { + final payload = {'foo': _NonEncodable()}; + final entry = structuredLogEntry( + 'hello', + LogSeverity.info, + payload: payload, + ); + final map = jsonDecode(entry) as Map; + expect(map, { + 'foo': 'I am not encodable', + 'message': 'hello', + 'severity': 'INFO', + }); + }); + + test('cyclic payload drops payload and stringifies message', () { + final payload = {}; + payload['cycle'] = payload; + final message = _NonEncodable(); + final entry = structuredLogEntry( + message, + LogSeverity.info, + payload: payload, + ); + final map = jsonDecode(entry) as Map; + expect(map, {'message': 'I am not encodable', 'severity': 'INFO'}); }); }); @@ -74,33 +183,38 @@ void main() { }); }); - group('RequestLogger (default)', () { + group('CloudLogger.defaultLogger()', () { + const logger = CloudLogger.defaultLogger(); + test('log with default severity', () { - final output = []; - runZoned( - () => currentLogger.log('hello', LogSeverity.defaultSeverity), - zoneSpecification: ZoneSpecification( - print: (self, parent, zone, line) => output.add(line), - ), + expect( + () => logger.log('hello', LogSeverity.defaultSeverity), + prints('hello\n'), ); - expect(output, ['hello']); }); test('log with explicit severity', () { - final output = []; - runZoned( - () => currentLogger.log('hello', LogSeverity.error), - zoneSpecification: ZoneSpecification( - print: (self, parent, zone, line) => output.add(line), + expect( + () => logger.log('hello', LogSeverity.error), + prints('ERROR: hello\n'), + ); + }); + + test('log with payload and labels', () { + expect( + () => logger.log( + 'hello', + LogSeverity.error, + payload: {'foo': 'bar'}, + labels: {'env': 'test'}, ), + prints('ERROR: hello {foo: bar} {env: test}\n'), ); - expect(output, ['ERROR: hello']); }); }); group('middleware', () { test('cloudLoggingMiddleware logs structured entries', () async { - final output = []; final handler = const Pipeline() .addMiddleware(cloudLoggingMiddleware('test-project')) .addHandler((request) { @@ -108,7 +222,7 @@ void main() { return Response.ok('done'); }); - await runZoned( + await expectLater( () => handler( Request( 'GET', @@ -116,20 +230,14 @@ void main() { headers: {'x-cloud-trace-context': 'trace-456/123;o=1'}, ), ), - zoneSpecification: ZoneSpecification( - print: (self, parent, zone, line) => output.add(line), - ), - ); - - expect(output, hasLength(1)); - final map = jsonDecode(output.single) as Map; - expect(map, containsPair('message', 'inner log')); - expect(map, containsPair('severity', 'INFO')); - expect( - map, - containsPair( - 'logging.googleapis.com/trace', - 'projects/test-project/traces/trace-456', + prints( + predicate((output) { + final map = jsonDecode(output) as Map; + return map['message'] == 'inner log' && + map['severity'] == 'INFO' && + map['logging.googleapis.com/trace'] == + 'projects/test-project/traces/trace-456'; + }), ), ); }); diff --git a/pkgs/google_cloud_storage/pubspec.yaml b/pkgs/google_cloud_storage/pubspec.yaml index c5ef7039..8f2d450b 100644 --- a/pkgs/google_cloud_storage/pubspec.yaml +++ b/pkgs/google_cloud_storage/pubspec.yaml @@ -29,7 +29,7 @@ dependencies: collection: ^1.19.1 crypto: ^3.0.7 ffi: ^2.2.0 - google_cloud: ^0.3.0 + google_cloud: '>=0.3.0 <0.5.0' google_cloud_protobuf: ^0.5.0 google_cloud_rpc: ^0.5.0 googleapis_auth: ^2.0.0