Skip to content

chore: use google_cloud_(shelf/logging)#184

Draft
brianquinlan wants to merge 8 commits into
firebase:mainfrom
brianquinlan:logging
Draft

chore: use google_cloud_(shelf/logging)#184
brianquinlan wants to merge 8 commits into
firebase:mainfrom
brianquinlan:logging

Conversation

@brianquinlan

Copy link
Copy Markdown
Contributor

No description provided.

@brianquinlan brianquinlan marked this pull request as draft May 13, 2026 23:45

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the logging infrastructure to use the google_cloud_logging and google_cloud_shelf packages, removing the custom logger implementation and manual trace propagation logic. The reviewer recommended several enhancements: providing a deprecated warn extension for backward compatibility, ensuring project context is preserved during function registration using runWithLogger, preventing duplicate logs in the emulator environment, and correctly passing the port configuration to the server handler.

Comment thread lib/src/logger/logger.dart Outdated
Comment on lines +15 to +19
import 'package:google_cloud_logging/google_cloud_logging.dart';
import 'package:google_cloud_shelf/google_cloud_shelf.dart';

import 'package:meta/meta.dart';

/// Log severity levels for Cloud Logging.
///
/// See [LogSeverity](https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity).
enum LogSeverity {
debug('DEBUG'),
info('INFO'),
notice('NOTICE'),
warning('WARNING'),
error('ERROR'),
critical('CRITICAL'),
alert('ALERT'),
emergency('EMERGENCY');

const LogSeverity(this.value);

/// The string value used in Cloud Logging JSON entries.
final String value;
}

/// A structured Cloud Logging log entry.
///
/// A [Map] where `severity` (required) and `message` (optional) are standard
/// Cloud Logging fields. All other keys are included in the `jsonPayload`
/// of the logged entry.
typedef LogEntry = Map<String, Object?>;

/// Removes circular references from an object graph for safe JSON
/// serialization.
///
/// Returns a new data structure with circular references replaced by the
/// string `"[Circular]"`. Does not mutate the original object.
///
/// Handles [DateTime] by converting to ISO 8601 UTC string and respects
/// objects with a `toJson()` method.
Object? removeCircular(Object? obj, [Set<Object>? existingRefs]) {
if (obj == null || obj is bool || obj is num || obj is String) {
return obj;
}

if (obj is DateTime) {
return obj.toUtc().toIso8601String();
}

final refs = existingRefs ?? {};

// Handle objects with toJson() (custom serializable types).
if (obj is! Map && obj is! List) {
try {
final result = (obj as dynamic).toJson();
return removeCircular(result, refs);
} catch (_) {
return obj.toString();
}
}

if (refs.contains(obj)) {
return '[Circular]';
}
refs.add(obj);

late final Object? result;

if (obj is Map) {
final map = <String, Object?>{};
for (final MapEntry(:key, :value) in obj.entries) {
try {
if (value != null && refs.contains(value)) {
map[key.toString()] = '[Circular]';
} else {
map[key.toString()] = removeCircular(value, refs);
}
} catch (_) {
map[key.toString()] = '[Error - cannot serialize]';
}
}
result = map;
} else {
// obj is List
result = List<Object?>.generate((obj as List).length, (i) {
final value = obj[i];
try {
if (value != null && refs.contains(value)) {
return '[Circular]';
}
return removeCircular(value, refs);
} catch (_) {
return '[Error - cannot serialize]';
}
});
}

refs.remove(obj);
return result;
}

/// Whether a severity level should be written to stderr.
bool _isStderrSeverity(String severity) => switch (severity) {
'WARNING' || 'ERROR' || 'CRITICAL' || 'ALERT' || 'EMERGENCY' => true,
_ => false,
};

/// Creates a new [Logger] instance.
///
/// [stdoutWriter] and [stderrWriter] can be provided for testing.
@internal
Logger createLogger({
void Function(String line)? stdoutWriter,
void Function(String line)? stderrWriter,
}) => Logger._(stdoutWriter: stdoutWriter, stderrWriter: stderrWriter);

/// Structured logger for Cloud Logging, compatible with the Firebase
/// Functions Node.js SDK `logger` namespace.
///
/// Writes JSON-formatted [LogEntry] objects to stdout or stderr depending
/// on severity. DEBUG, INFO, and NOTICE go to stdout; WARNING, ERROR,
/// CRITICAL, ALERT, and EMERGENCY go to stderr.
///
/// ## Usage
///
/// ```dart
/// import 'package:firebase_functions/logger.dart';
///
/// // Simple message
/// logger.info('Request received');
///
/// // Message with structured data
/// logger.info('Processing', {'userId': '123', 'action': 'update'});
///
/// // Structured data only (message inside the map is preserved)
/// logger.info({'message': 'Custom', 'requestId': 'abc'});
///
/// // Low-level structured entry
/// logger.write({'severity': 'NOTICE', 'message': 'Custom', 'code': 42});
/// ```
final class Logger {
/// Creates a [Logger] instance.
///
/// Custom [stdoutWriter] and [stderrWriter] can be provided for testing.
Logger._({
void Function(String line)? stdoutWriter,
void Function(String line)? stderrWriter,
}) : _stdoutWriter = stdoutWriter ?? _defaultStdoutWriter,
_stderrWriter = stderrWriter ?? _defaultStderrWriter;

final void Function(String line) _stdoutWriter;
final void Function(String line) _stderrWriter;

static void _defaultStdoutWriter(String line) => io.stdout.writeln(line);
static void _defaultStderrWriter(String line) => io.stderr.writeln(line);

/// Writes a [LogEntry] to stdout or stderr depending on severity.
///
/// The entry must contain a `severity` key. If a trace ID is available
/// in the current [Zone] (via [traceIdZoneKey]), it is automatically added
/// to the entry.
void write(LogEntry entry) {
// Add trace context if available.

final projectId = Zone.current[projectIdZoneKey] as String?;
final traceId = Zone.current[traceIdZoneKey] as String?;

if (projectId != null && traceId != null) {
assert(projectId.isNotEmpty, 'projectIdZoneKey value must not be empty');
assert(traceId.isNotEmpty, 'traceIdZoneKey value must not be empty');
entry['logging.googleapis.com/trace'] =
'projects/$projectId/traces/$traceId';
}

final sanitized = removeCircular(entry);
final json = jsonEncode(sanitized);

final severity = entry['severity'] as String? ?? 'INFO';
if (_isStderrSeverity(severity)) {
_stderrWriter(json);
} else {
_stdoutWriter(json);
}
}

/// Writes a DEBUG severity log.
///
/// If [messageOrPayload] is a [Map<String, Object?>] and [jsonPayload]
/// is null, the map is used directly as the structured log entry
/// (preserving any `message` key within it).
///
/// Otherwise, [messageOrPayload] is converted to a string for the
/// `message` field, and [jsonPayload] entries are merged into the entry.
void debug(Object? messageOrPayload, [Map<String, Object?>? jsonPayload]) {
write(_entryFromArgs('DEBUG', messageOrPayload, jsonPayload));
}

/// Writes an INFO severity log. Alias for [info].
void log(Object? messageOrPayload, [Map<String, Object?>? jsonPayload]) {
write(_entryFromArgs('INFO', messageOrPayload, jsonPayload));
}

/// Writes an INFO severity log.
void info(Object? messageOrPayload, [Map<String, Object?>? jsonPayload]) {
write(_entryFromArgs('INFO', messageOrPayload, jsonPayload));
}

/// Writes a WARNING severity log.
void warn(Object? messageOrPayload, [Map<String, Object?>? jsonPayload]) {
write(_entryFromArgs('WARNING', messageOrPayload, jsonPayload));
}

/// Writes an ERROR severity log.
void error(Object? messageOrPayload, [Map<String, Object?>? jsonPayload]) {
write(_entryFromArgs('ERROR', messageOrPayload, jsonPayload));
}
}

/// Constructs a [LogEntry] from a severity, message, and optional JSON
/// payload.
///
/// When [messageOrPayload] is a [Map<String, Object?>] and [jsonPayload]
/// is null, the map is used directly as structured data (matching Node.js
/// behavior where a lone plain-object argument is treated as the entry).
///
/// Otherwise, [messageOrPayload] is stringified and set as the `message`
/// field, overwriting any `message` key from [jsonPayload].
LogEntry _entryFromArgs(
String severity,
Object? messageOrPayload,
Map<String, Object?>? jsonPayload,
) {
// If only a Map was passed, treat it as structured data.
if (messageOrPayload is Map<String, Object?> && jsonPayload == null) {
return {...messageOrPayload, 'severity': severity};
}

final entry = <String, Object?>{...?jsonPayload, 'severity': severity};

final messageStr = '$messageOrPayload';
if (messageStr.isNotEmpty) {
entry['message'] = messageStr;
}

return entry;
}

/// Default [Logger] instance.
///
/// This is the primary way to use the logger:
/// ```dart
/// import 'package:firebase_functions/logger.dart';
///
/// logger.info('Hello');
/// logger.warn('Something is off', {'requestId': 'abc'});
/// ```
final logger = Logger._();

/// Standard HTTP header used by
/// [Cloud Trace](https://cloud.google.com/trace/docs/setup).
@internal
const cloudTraceContextHeader = 'x-cloud-trace-context';

/// Zone key for propagating trace IDs.
@internal
final Object traceIdZoneKey = Object();

/// Zone key for propagating project ID.
@internal
final Object projectIdZoneKey = Object();
/// The default instance used for logging in the current context.
CloudLogger get logger => currentLogger;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logger implementation should export package:google_cloud_logging to provide users with access to the CloudLogger type and utilities like runWithLogger. Additionally, adding a deprecated warn extension helps maintain backward compatibility for existing codebases that haven't migrated to the warning method name yet.

Suggested change
import 'package:google_cloud_logging/google_cloud_logging.dart';
import 'package:google_cloud_shelf/google_cloud_shelf.dart';
import 'package:meta/meta.dart';
/// Log severity levels for Cloud Logging.
///
/// See [LogSeverity](https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity).
enum LogSeverity {
debug('DEBUG'),
info('INFO'),
notice('NOTICE'),
warning('WARNING'),
error('ERROR'),
critical('CRITICAL'),
alert('ALERT'),
emergency('EMERGENCY');
const LogSeverity(this.value);
/// The string value used in Cloud Logging JSON entries.
final String value;
}
/// A structured Cloud Logging log entry.
///
/// A [Map] where `severity` (required) and `message` (optional) are standard
/// Cloud Logging fields. All other keys are included in the `jsonPayload`
/// of the logged entry.
typedef LogEntry = Map<String, Object?>;
/// Removes circular references from an object graph for safe JSON
/// serialization.
///
/// Returns a new data structure with circular references replaced by the
/// string `"[Circular]"`. Does not mutate the original object.
///
/// Handles [DateTime] by converting to ISO 8601 UTC string and respects
/// objects with a `toJson()` method.
Object? removeCircular(Object? obj, [Set<Object>? existingRefs]) {
if (obj == null || obj is bool || obj is num || obj is String) {
return obj;
}
if (obj is DateTime) {
return obj.toUtc().toIso8601String();
}
final refs = existingRefs ?? {};
// Handle objects with toJson() (custom serializable types).
if (obj is! Map && obj is! List) {
try {
final result = (obj as dynamic).toJson();
return removeCircular(result, refs);
} catch (_) {
return obj.toString();
}
}
if (refs.contains(obj)) {
return '[Circular]';
}
refs.add(obj);
late final Object? result;
if (obj is Map) {
final map = <String, Object?>{};
for (final MapEntry(:key, :value) in obj.entries) {
try {
if (value != null && refs.contains(value)) {
map[key.toString()] = '[Circular]';
} else {
map[key.toString()] = removeCircular(value, refs);
}
} catch (_) {
map[key.toString()] = '[Error - cannot serialize]';
}
}
result = map;
} else {
// obj is List
result = List<Object?>.generate((obj as List).length, (i) {
final value = obj[i];
try {
if (value != null && refs.contains(value)) {
return '[Circular]';
}
return removeCircular(value, refs);
} catch (_) {
return '[Error - cannot serialize]';
}
});
}
refs.remove(obj);
return result;
}
/// Whether a severity level should be written to stderr.
bool _isStderrSeverity(String severity) => switch (severity) {
'WARNING' || 'ERROR' || 'CRITICAL' || 'ALERT' || 'EMERGENCY' => true,
_ => false,
};
/// Creates a new [Logger] instance.
///
/// [stdoutWriter] and [stderrWriter] can be provided for testing.
@internal
Logger createLogger({
void Function(String line)? stdoutWriter,
void Function(String line)? stderrWriter,
}) => Logger._(stdoutWriter: stdoutWriter, stderrWriter: stderrWriter);
/// Structured logger for Cloud Logging, compatible with the Firebase
/// Functions Node.js SDK `logger` namespace.
///
/// Writes JSON-formatted [LogEntry] objects to stdout or stderr depending
/// on severity. DEBUG, INFO, and NOTICE go to stdout; WARNING, ERROR,
/// CRITICAL, ALERT, and EMERGENCY go to stderr.
///
/// ## Usage
///
/// ```dart
/// import 'package:firebase_functions/logger.dart';
///
/// // Simple message
/// logger.info('Request received');
///
/// // Message with structured data
/// logger.info('Processing', {'userId': '123', 'action': 'update'});
///
/// // Structured data only (message inside the map is preserved)
/// logger.info({'message': 'Custom', 'requestId': 'abc'});
///
/// // Low-level structured entry
/// logger.write({'severity': 'NOTICE', 'message': 'Custom', 'code': 42});
/// ```
final class Logger {
/// Creates a [Logger] instance.
///
/// Custom [stdoutWriter] and [stderrWriter] can be provided for testing.
Logger._({
void Function(String line)? stdoutWriter,
void Function(String line)? stderrWriter,
}) : _stdoutWriter = stdoutWriter ?? _defaultStdoutWriter,
_stderrWriter = stderrWriter ?? _defaultStderrWriter;
final void Function(String line) _stdoutWriter;
final void Function(String line) _stderrWriter;
static void _defaultStdoutWriter(String line) => io.stdout.writeln(line);
static void _defaultStderrWriter(String line) => io.stderr.writeln(line);
/// Writes a [LogEntry] to stdout or stderr depending on severity.
///
/// The entry must contain a `severity` key. If a trace ID is available
/// in the current [Zone] (via [traceIdZoneKey]), it is automatically added
/// to the entry.
void write(LogEntry entry) {
// Add trace context if available.
final projectId = Zone.current[projectIdZoneKey] as String?;
final traceId = Zone.current[traceIdZoneKey] as String?;
if (projectId != null && traceId != null) {
assert(projectId.isNotEmpty, 'projectIdZoneKey value must not be empty');
assert(traceId.isNotEmpty, 'traceIdZoneKey value must not be empty');
entry['logging.googleapis.com/trace'] =
'projects/$projectId/traces/$traceId';
}
final sanitized = removeCircular(entry);
final json = jsonEncode(sanitized);
final severity = entry['severity'] as String? ?? 'INFO';
if (_isStderrSeverity(severity)) {
_stderrWriter(json);
} else {
_stdoutWriter(json);
}
}
/// Writes a DEBUG severity log.
///
/// If [messageOrPayload] is a [Map<String, Object?>] and [jsonPayload]
/// is null, the map is used directly as the structured log entry
/// (preserving any `message` key within it).
///
/// Otherwise, [messageOrPayload] is converted to a string for the
/// `message` field, and [jsonPayload] entries are merged into the entry.
void debug(Object? messageOrPayload, [Map<String, Object?>? jsonPayload]) {
write(_entryFromArgs('DEBUG', messageOrPayload, jsonPayload));
}
/// Writes an INFO severity log. Alias for [info].
void log(Object? messageOrPayload, [Map<String, Object?>? jsonPayload]) {
write(_entryFromArgs('INFO', messageOrPayload, jsonPayload));
}
/// Writes an INFO severity log.
void info(Object? messageOrPayload, [Map<String, Object?>? jsonPayload]) {
write(_entryFromArgs('INFO', messageOrPayload, jsonPayload));
}
/// Writes a WARNING severity log.
void warn(Object? messageOrPayload, [Map<String, Object?>? jsonPayload]) {
write(_entryFromArgs('WARNING', messageOrPayload, jsonPayload));
}
/// Writes an ERROR severity log.
void error(Object? messageOrPayload, [Map<String, Object?>? jsonPayload]) {
write(_entryFromArgs('ERROR', messageOrPayload, jsonPayload));
}
}
/// Constructs a [LogEntry] from a severity, message, and optional JSON
/// payload.
///
/// When [messageOrPayload] is a [Map<String, Object?>] and [jsonPayload]
/// is null, the map is used directly as structured data (matching Node.js
/// behavior where a lone plain-object argument is treated as the entry).
///
/// Otherwise, [messageOrPayload] is stringified and set as the `message`
/// field, overwriting any `message` key from [jsonPayload].
LogEntry _entryFromArgs(
String severity,
Object? messageOrPayload,
Map<String, Object?>? jsonPayload,
) {
// If only a Map was passed, treat it as structured data.
if (messageOrPayload is Map<String, Object?> && jsonPayload == null) {
return {...messageOrPayload, 'severity': severity};
}
final entry = <String, Object?>{...?jsonPayload, 'severity': severity};
final messageStr = '$messageOrPayload';
if (messageStr.isNotEmpty) {
entry['message'] = messageStr;
}
return entry;
}
/// Default [Logger] instance.
///
/// This is the primary way to use the logger:
/// ```dart
/// import 'package:firebase_functions/logger.dart';
///
/// logger.info('Hello');
/// logger.warn('Something is off', {'requestId': 'abc'});
/// ```
final logger = Logger._();
/// Standard HTTP header used by
/// [Cloud Trace](https://cloud.google.com/trace/docs/setup).
@internal
const cloudTraceContextHeader = 'x-cloud-trace-context';
/// Zone key for propagating trace IDs.
@internal
final Object traceIdZoneKey = Object();
/// Zone key for propagating project ID.
@internal
final Object projectIdZoneKey = Object();
/// The default instance used for logging in the current context.
CloudLogger get logger => currentLogger;
export 'package:google_cloud_logging/google_cloud_logging.dart';
import 'package:google_cloud_logging/google_cloud_logging.dart';
import 'package:google_cloud_shelf/google_cloud_shelf.dart';
/// The default instance used for logging in the current context.
CloudLogger get logger => currentLogger;
extension LoggerCompatibility on CloudLogger {
/// Deprecated alias for [warning].
@Deprecated('Use warning instead')
void warn(Object? message, [Map<String, Object?>? payload]) =>
warning(message, payload: payload);
}

Comment thread lib/src/server.dart
import 'dart:convert';
import 'dart:io';

import 'package:google_cloud_shelf/google_cloud_shelf.dart';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import package:google_cloud_logging to enable the use of CloudLogger and runWithLogger within the server implementation.

Suggested change
import 'package:google_cloud_shelf/google_cloud_shelf.dart';
import 'package:google_cloud_logging/google_cloud_logging.dart';
import 'package:google_cloud_shelf/google_cloud_shelf.dart';

Comment thread lib/src/server.dart
Comment on lines +72 to +73
// Run user's function registration code
await runner(firebase);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Logs emitted during the function registration phase (runner(firebase)) currently lack the projectId context because the previous runZoned wrapper was removed. Wrapping this call in runWithLogger ensures that initialization logs are correctly associated with the project in Cloud Logging.

Suggested change
// Run user's function registration code
await runner(firebase);
// Run user's function registration code
await runWithLogger(CloudLogger(projectId: projectId), () async {
await runner(firebase);
});

Comment thread lib/src/server.dart
Comment on lines +86 to +88
middleware = middleware.addMiddleware(
createLoggingMiddleware(projectId: projectId),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To avoid duplicate request logs in the emulator (where logRequests() is already active at line 83), you can disable request logging in createLoggingMiddleware while still maintaining the necessary logging context for the request handlers.

  middleware = middleware.addMiddleware(
    createLoggingMiddleware(
      projectId: projectId,
      logRequests: !env.isEmulator,
    ),
  );

Comment thread lib/src/server.dart
});

// Start HTTP server
await serveHandler(handler);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The serveHandler call should explicitly include the port parameter to respect the configuration in env.port, maintaining consistency with the previous shelf_io.serve implementation.

Suggested change
await serveHandler(handler);
await serveHandler(handler, port: env.port);

Comment thread lib/src/server.dart
final env = firebase.$env;
final projectId = env.projectId;

await runZoned(zoneValues: {projectIdZoneKey: projectId}, () async {

@brianquinlan brianquinlan May 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming that this is only done for log correlation. The cloudLoggingMiddleware creates a zone for the same purpose.

logging done by runner outside of the context of the middleware (aka request) will use currentLogger (which will be printLogger because there is no logging installed in the zone [I think]).

@kevmoo would it make sense to have an option where google_cloud_shelf can use a structuredLogger for currentLogger if no logger is specified in the zone? We'd use that option here if env.isEmulator is false (i.e. we are running in production).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I see where you're going, but I need more details...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm out until Monday. Let's talk then.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants