Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
90af25c
feat(llc)!: rebuild the logger as one any Stream SDK can write to
xsahil03x Aug 24, 2026
c700e42
feat(llc): report the connection lifecycle through the logger
xsahil03x Aug 24, 2026
c9756ef
feat(llc): report what the auth interceptor decided
xsahil03x Aug 24, 2026
6c1cde3
fix(llc)!: stop the logging interceptor printing every request
xsahil03x Aug 24, 2026
864a823
feat(llc): let a product client settle logging in one step
xsahil03x Aug 24, 2026
7e5b3c4
docs(llc): say that a config replaces the filter rather than joining it
xsahil03x Aug 24, 2026
fc39dfc
docs(llc): say what a config means for the SDK beside the one it came…
xsahil03x Aug 24, 2026
617bce1
feat(llc): let a product's config govern only that product's records
xsahil03x Aug 24, 2026
7b22162
refactor(llc)!: name the tag tree what it is
xsahil03x Aug 24, 2026
4c06abc
Revert "feat(llc): let a product's config govern only that product's …
xsahil03x Aug 24, 2026
027df46
docs(llc): show the console half of a composite wrapped in debugOnly
xsahil03x Aug 24, 2026
d1ea548
refactor(llc): put the ambient handler and filter on a root logger
xsahil03x Aug 24, 2026
3d3c37f
Revert "refactor(llc): put the ambient handler and filter on a root l…
xsahil03x Aug 24, 2026
0bb9563
refactor(llc): let a handler hold a filter rather than a priority
xsahil03x Aug 24, 2026
b8456e2
fix(llc): refuse `none` as a record's priority
xsahil03x Aug 24, 2026
aa0caff
refactor(llc): fold the filter and handler into one validator
xsahil03x Aug 24, 2026
a340b8b
refactor(llc)!: leave the filter as the only thing that gates a record
xsahil03x Aug 25, 2026
d72eea7
docs(llc): drop the console from a composite rather than branching ar…
xsahil03x Aug 25, 2026
5c47a03
refactor(llc)!: let the filter alone say whether a record is built
xsahil03x Aug 25, 2026
7517429
refactor(llc): drop the compiled validator, which now wraps one call
xsahil03x Aug 25, 2026
cadaf92
fix(llc): put back the guard against `none` as a record's priority
xsahil03x Aug 25, 2026
b904abd
docs(llc): say what `none` is in two lines
xsahil03x Aug 25, 2026
5a378c8
refactor(llc): let a filter decide what a threshold of `none` admits
xsahil03x Aug 25, 2026
3e9c08c
refactor(llc)!: call it a level, which is how it is used
xsahil03x Aug 25, 2026
684ec5c
Revert "refactor(llc)!: call it a level, which is how it is used"
xsahil03x Aug 25, 2026
a6be148
fix(llc): stop the HTTP interceptor writing empty records
xsahil03x Aug 25, 2026
454c484
Revert "fix(llc): stop the HTTP interceptor writing empty records"
xsahil03x Aug 25, 2026
08ba72e
feat(llc): let `User.guest` carry an image
xsahil03x Aug 25, 2026
806a66c
docs(changelog): say the interceptor waits to be asked, not to be han…
xsahil03x Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/stream_core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,18 @@
- `WebSocketConnectionState.isAutomaticReconnectionEnabled` is now `true` for an expired token, and remains `false` for token errors a fresh token cannot fix
- `StreamApiError.isTokenExpiredError` now means code 40 only; the other token codes and a wrong API key are `isInvalidTokenError`. `isClientError` compares the HTTP `statusCode` against 400..499, rather than the Stream error `code`, which never falls in that range
- `Result.getOrElse`, `getOrDefault`, `recover` and `recoverCatching` return the result's own type and no longer take a type parameter. To widen, widen the result (`Result<num> widened = intResult`) or use `fold`
- Replaced the logger: `StreamLogger` is the handle you write with and a `StreamLogHandler` is where records go, so `Priority`, `MessageBuilder`, `Tag`, `IsLoggableValidator` and `Finder` are renamed or gone
- `LoggingInterceptor` writes through the logger rather than printing, so it is silent until an app asks for records. Its `logPrint` is now optional, and it takes a `tag`

### ✨ Features

- Added a logger the SDK now reports itself through, silent until an app names both a destination and a priority on `StreamLogger`, or hands a product client a `StreamLogConfig` carrying both
- Added `TokenManager.setTokenProvider`, which points an existing manager at another user and expires the cached token; handed the identity it already has, it does nothing
- Added optional `onTokenUpdated` callback to `TokenManager`, invoked after every successful token load
- Added optional `rawValue` to `UserToken.anonymous`, so an anonymous token can carry a JWT granting restricted access; its `user_id` claim must be `!anon`
- Added `UserToken.expiresAt`, from the token's `exp` claim, and `UserToken.isExpired`, which takes an optional `leeway`
- Added `User.anonymousUserId`, the id every anonymous user has
- `User.guest` takes an `image`, which it previously dropped
- Added `TokenManager.unconfigured`, for a client that exists before its user does, and `TokenManager.reset`, which drops the configured identity and its cached token
- Added `teams` field to `User` class
- Added `DioException.apiError`, the Stream API error a response carried, or `null` for anything else
Expand Down
14 changes: 14 additions & 0 deletions packages/stream_core/analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
include: ../../analysis_options.yaml

analyzer:
exclude:
# The repository excludes generated files by a path relative to its own options file, which
# stops matching once that file is included from here.
- lib/**/*.*.dart

linter:
rules:
# Dangling dartdoc links are invisible to the analyzer everywhere else in the repo, so a rename
# leaves references pointing at members that no longer exist. Kept on here, where the public
# API is documented heavily enough for that to matter.
comment_references: true
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import 'package:dio/dio.dart';

import '../../errors.dart';
import '../../logger.dart';
import '../../user.dart';
import '../stream_core_dio_error.dart';

/// Interceptor that signs every request with the caller's token.
///
/// A request the server refuses for an expired token is retried once, carrying a replacement.
///
/// Reports what it decided under `SC:HttpAuth`, including every reason it left a refused request
/// refused. Nothing is written until an app installs a [StreamLogHandler].
class AuthInterceptor extends Interceptor {
/// Creates a new [AuthInterceptor].
AuthInterceptor(this._dio, this._tokenManager);
AuthInterceptor(this._dio, this._tokenManager, {String tag = 'SC:HttpAuth'}) : _logger = StreamLogger(tag);

final Dio _dio;
final TokenManager _tokenManager;
final StreamLogger _logger;

// Not a `QueuedInterceptor`: it frees a slot only once a handler completes, so the retry sent from
// `onError` would wait behind the request holding it. `TokenManager` serialises the token loads.
Expand All @@ -33,6 +38,8 @@ class AuthInterceptor extends Interceptor {

return handler.next(options);
} catch (e, stackTrace) {
_logger.w(() => 'no token to sign ${options.uri} with', error: e, stackTrace: stackTrace);

final error = ClientException(
message: 'Failed to load auth token',
stackTrace: stackTrace,
Expand Down Expand Up @@ -62,9 +69,23 @@ class AuthInterceptor extends Interceptor {
// A retry after a user switch would perform this request as the new user.
final signedFor = options.queryParameters['user_id'];
final canRefresh = signedFor == _tokenManager.userId && !_tokenManager.usesStaticProvider;
if (!canRefresh) return handler.next(err);
if (!canRefresh) {
_logger.d(() {
final reason = switch (_tokenManager.usesStaticProvider) {
true => 'the token provider is static and has nothing fresher to give',
false => 'it was signed for $signedFor, and the user is now ${_tokenManager.userId}',
};

if (options.extra[_retriedKey] == true) return handler.next(err);
return 'not refreshing the token behind ${options.uri}: $reason';
});

return handler.next(err);
}

if (options.extra[_retriedKey] == true) {
_logger.w(() => 'the replacement token was refused too, leaving ${options.uri} failed');
return handler.next(err);
}

// Another request may have replaced it already, and expiring that would discard a valid token.
if (options.headers['Authorization'] == _tokenManager.peekToken()?.rawValue) {
Expand All @@ -78,11 +99,14 @@ class AuthInterceptor extends Interceptor {
data: data is FormData ? data.clone() : data,
);

_logger.d(() => 'retrying ${options.uri} with a replacement token');

try {
// ignore: inference_failure_on_function_invocation
final response = await _dio.fetch(retry);
return handler.resolve(response);
} on DioException catch (exception) {
_logger.w(() => 'the retry of ${options.uri} failed too', error: exception);
return handler.reject(exception);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,31 @@ import 'dart:math' as math;

import 'package:dio/dio.dart';

/// Step where we're logging
import '../../logger.dart';

/// The stage of a request a record came from.
enum InterceptStep {
/// Request
/// A request on its way out.
request,

/// Response
/// A response that came back.
response,

/// Error
/// A request that failed.
error,
}

/// Function used to print the log
/// Takes one line of the log, in place of the logger.
typedef LogPrint = void Function(InterceptStep step, Object object);

void _defaultLogPrint(InterceptStep step, Object object) => print(object);

/// Interceptor dedicated to logging
/// An interceptor that reports each request and the response it gets.
///
/// Records go out under `SC:Http`, at [StreamLogPriority.debug], or [StreamLogPriority.warning]
/// for a request that failed. Nothing is written, or even formatted, until an app installs a
/// [StreamLogHandler].
///
/// [requestHeader] puts the `Authorization` header in the record along with the rest, so consider
/// what reads these before turning it on.
class LoggingInterceptor extends Interceptor {
/// Creates a new [LoggingInterceptor].
LoggingInterceptor({
Expand All @@ -33,46 +40,67 @@ class LoggingInterceptor extends Interceptor {
this.error = true,
this.maxWidth = 120,
this.compact = true,
this.logPrint = _defaultLogPrint,
});
this.logPrint,
String tag = 'SC:Http',
}) : _logger = StreamLogger(tag);

final StreamLogger _logger;

/// Print request [Options]
/// Whether to report the request line.
final bool request;

/// Print request header [Options.headers]
/// Whether to report the request's headers, query parameters and extras.
final bool requestHeader;

/// Print request data [RequestOptions.data]
/// Whether to report the request body.
final bool requestBody;

/// Print [Response.data]
/// Whether to report the response body.
final bool responseBody;

/// Print [Response.headers]
/// Whether to report the response headers.
final bool responseHeader;

/// Print error message
/// Whether to report a request that failed.
final bool error;

/// InitialTab count to logPrint json response
/// The indent a nested value starts at.
static const initialTab = 1;

/// 1 tab length
/// One level of indent.
static const tabStep = ' ';

/// Print compact json response
/// Whether to report a nested map on one line, rather than one key per line.
final bool compact;

/// Width size per logPrint
/// The width a line is wrapped at.
final int maxWidth;

/// Log printer; defaults logPrint log to console.
/// In flutter, you'd better use debugPrint.
/// you can also write log in a file.
void Function(InterceptStep step, Object object) logPrint;
/// Takes each line instead of the logger, for a caller routing them somewhere of its own.
final LogPrint? logPrint;

// Consulted before a line is formatted, so a request costs nothing while nothing wants it.
bool _wants(InterceptStep step) {
if (logPrint != null) return true;
return _logger.isLoggable(_priorityOf(step));
}

StreamLogPriority _priorityOf(InterceptStep step) {
return switch (step) {
InterceptStep.error => StreamLogPriority.warning,
InterceptStep.request || InterceptStep.response => StreamLogPriority.debug,
};
}

void _write(InterceptStep step, Object object) {
if (logPrint case final logPrint?) return logPrint(step, object);
return _logger.log(_priorityOf(step), () => '$object');
}

@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (!_wants(InterceptStep.request)) return super.onRequest(options, handler);

if (request) {
_printRequestHeader(_logPrintRequest, options);
}
Expand Down Expand Up @@ -119,6 +147,8 @@ class LoggingInterceptor extends Interceptor {

@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (!_wants(InterceptStep.error)) return super.onError(err, handler);

if (error) {
if (err.type == DioExceptionType.badResponse) {
final uri = err.response?.requestOptions.uri;
Expand Down Expand Up @@ -150,6 +180,8 @@ class LoggingInterceptor extends Interceptor {
Response<dynamic> response,
ResponseInterceptorHandler handler,
) {
if (!_wants(InterceptStep.response)) return super.onResponse(response, handler);

_printResponseHeader(_logPrintResponse, response);
if (responseHeader) {
final responseHeaders = <String, String>{};
Expand Down Expand Up @@ -348,9 +380,9 @@ class LoggingInterceptor extends Interceptor {
_printLine(logPrint, '╚');
}

void _logPrintRequest(Object object) => logPrint(InterceptStep.request, object);
void _logPrintRequest(Object object) => _write(InterceptStep.request, object);

void _logPrintResponse(Object object) => logPrint(InterceptStep.response, object);
void _logPrintResponse(Object object) => _write(InterceptStep.response, object);

void _logPrintError(Object object) => logPrint(InterceptStep.error, object);
void _logPrintError(Object object) => _write(InterceptStep.error, object);
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class AttachmentUploadException implements Exception {
/// );
/// ```
class StreamAttachmentUploader {
/// Creates a [StreamAttachmentUploader] with the specified [cdn] client.
/// Creates a [StreamAttachmentUploader] uploading through the given [CdnClient].
const StreamAttachmentUploader({
required this._cdn,
});
Expand Down
5 changes: 5 additions & 0 deletions packages/stream_core/lib/src/logger.dart
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
export 'logger/stream_log_config.dart';
export 'logger/stream_log_filter.dart';
export 'logger/stream_log_handler.dart';
export 'logger/stream_log_priority.dart';
export 'logger/stream_log_record.dart';
export 'logger/stream_logger.dart';
27 changes: 0 additions & 27 deletions packages/stream_core/lib/src/logger/impl/external_logger.dart

This file was deleted.

Loading
Loading