Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,52 @@ The SDK development workflow:

The SDK follows .NET best practices and conventions while providing a clean, maintainable codebase for GetStream's Feeds API integration.

## Structured Logging

Pass `Logger` on `StreamOptions` (or `ClientBuilder.Logger(...)`) to receive structured events via `Microsoft.Extensions.Logging.ILogger`. No logger set means no output; the SDK never changes the logger's configured level. Four canonical events, matching the cross-SDK logging spec:

| Event (dotted name, message prefix) | Level | Emitted |
|---|---|---|
| `client.initialized` | INFO | Once, at `BaseClient` construction |
| `http.request.sent` | DEBUG | Before every request is sent |
| `http.response.received` | DEBUG | After any HTTP response, including 4xx/5xx |
| `http.request.failed` | ERROR | Only on a transport failure (no HTTP response received) |

.NET's `ILogger` uses PascalCase message-template placeholders (`{Method}`, `{StatusCode}`, ...). Each maps to a canonical snake_case field name used identically across all GetStream SDKs:

| Event | Placeholder | Canonical field |
|---|---|---|
| `client.initialized` | `{SdkName}` | `stream.sdk.name` |
| | `{SdkVersion}` | `stream.sdk.version` |
| | `{MaxConnsPerHost}` | `stream.client.max_conns_per_host` |
| | `{IdleTimeoutSeconds}` | `stream.client.idle_timeout_seconds` |
| | `{ConnectTimeoutSeconds}` | `stream.client.connect_timeout_seconds` |
| | `{RequestTimeoutSeconds}` | `stream.client.request_timeout_seconds` |
| | `{GzipEnabled}` | `stream.client.gzip_enabled` |
| | `{UserHttpClient}` | `stream.client.user_http_client` |
| | `{LogBodies}` | `stream.client.log_bodies` |
| `http.request.sent` | `{Method}` | `method` |
| | `{Path}` | `path` |
| | `{Query}` | `query` (redacted) |
| | `{Body}` | `body` (redacted; only present when `LogBodies=true`) |
| `http.response.received` | `{Method}` | `method` |
| | `{Path}` | `path` |
| | `{StatusCode}` | `status_code` |
| | `{BodySize}` | `body_size` (bytes) |
| | `{DurationMs}` | `duration_ms` |
| | `{Body}` | `body` (redacted; only present when `LogBodies=true`) |
| `http.request.failed` | `{Method}` | `method` |
| | `{Path}` | `path` |
| | `{ErrorType}` | `error.type` |
| | `{DurationMs}` | `duration_ms` |
| | `{Message}` | `error.message` (redacted) |

`error.type` is one of `connection_reset`, `timeout`, `dns_failure`, `tls_handshake_failed`, `unknown` (see `GetStreamTransportException.ErrorType`).

**Redaction (always on, no opt-out):** query values for `api_key`/`api_secret`/`token` (case-insensitive) become `<redacted>`; top-level JSON body keys `api_secret`/`token`/`password` become `<redacted>` (shallow, key names are preserved). No header values are ever logged. `error.message` is additionally scrubbed for any `api_key=`/`api_secret=`/`token=` value appearing anywhere in the free-form transport-exception text.

**Bodies are not logged by default.** Set `StreamOptions.LogBodies = true` (or `ClientBuilder.LogBodies(true)`) to opt in; body content is still key-redacted as above. Enabling it emits exactly one WARN line at construction.

## Release Process

Releases use two paths, both handled by `.github/workflows/release.yml`:
Expand Down
3 changes: 3 additions & 0 deletions src/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Grants the test assembly access to internal members (e.g. LogRedaction) without
// widening any public API surface. Test assembly name matches tests/stream-feed-net-test.csproj.
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("stream-feed-net-test")]
86 changes: 74 additions & 12 deletions src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public class BaseClient : IClient
private readonly HttpClient _httpClient;
private readonly Microsoft.Extensions.Logging.ILogger? _logger;
private readonly bool _userHttpClient;
private readonly bool _logBodies;
protected string ApiKey;
protected string ApiSecret;
protected string BaseUrl;
Expand Down Expand Up @@ -58,6 +59,7 @@ public BaseClient(StreamOptions opts)
ApiSecret = opts.ApiSecret;
BaseUrl = opts.BaseUrl;
_logger = opts.Logger;
_logBodies = opts.LogBodies;

if (opts.HttpClient != null)
{
Expand All @@ -78,7 +80,7 @@ public BaseClient(StreamOptions opts)
Converters = { new NanosecondTimestampConverter(), new FeedOwnCapabilityConverter(), new ChannelOwnCapabilityConverter(), new OwnCapabilityConverter() }
};

LogConstruction(opts);
LogInitialized(opts);
}

private static HttpClient BuildDefaultHttpClient(StreamOptions opts)
Expand All @@ -96,19 +98,34 @@ private static HttpClient BuildDefaultHttpClient(StreamOptions opts)
return new HttpClient(handler) { Timeout = opts.RequestTimeout };
}

private void LogConstruction(StreamOptions opts)
/// <summary>
/// Emits the <c>client.initialized</c> INFO event exactly once (logging spec §6.1), then a one-shot
/// WARN when <see cref="StreamOptions.LogBodies"/> is enabled. See README "Structured Logging" for
/// the PascalCase-placeholder-to-canonical-field mapping.
/// </summary>
private void LogInitialized(StreamOptions opts)
{
if (_logger == null) return;
if (_userHttpClient)
{
_logger.LogInformation(
"connection pool: user_http_client=true (5 knobs not applied)");
}
else

// The SDK only wires gzip itself on the default-built handler (CHA-2961); when the caller
// supplies their own HttpClient (escape hatch), gzip is entirely the caller's concern.
// Bools are rendered lowercase explicitly: bool.ToString() defaults to "True"/"False",
// which would be the only capitalized values among an otherwise all-lowercase field set
// shared verbatim across the 6-SDK logging spec.
var gzipEnabled = (!_userHttpClient).ToString().ToLowerInvariant();
var userHttpClient = _userHttpClient.ToString().ToLowerInvariant();
var logBodies = opts.LogBodies.ToString().ToLowerInvariant();

_logger.LogInformation(
"client.initialized stream.sdk.name={SdkName} stream.sdk.version={SdkVersion} stream.client.max_conns_per_host={MaxConnsPerHost} stream.client.idle_timeout_seconds={IdleTimeoutSeconds} stream.client.connect_timeout_seconds={ConnectTimeoutSeconds} stream.client.request_timeout_seconds={RequestTimeoutSeconds} stream.client.gzip_enabled={GzipEnabled} stream.client.user_http_client={UserHttpClient} stream.client.log_bodies={LogBodies}",
"getstream-net", VersionName,
opts.MaxConnsPerHost, opts.IdleTimeout.TotalSeconds, opts.ConnectTimeout.TotalSeconds, opts.RequestTimeout.TotalSeconds,
gzipEnabled, userHttpClient, logBodies);

if (opts.LogBodies)
{
_logger.LogInformation(
"connection pool: max_conns_per_host={MaxConnsPerHost} idle_timeout={IdleTimeout} connect_timeout={ConnectTimeout} request_timeout={RequestTimeout} user_http_client=false",
opts.MaxConnsPerHost, opts.IdleTimeout, opts.ConnectTimeout, opts.RequestTimeout);
_logger.LogWarning(
"HTTP request/response bodies will be logged. Auth headers and known-secret fields are still redacted, but other sensitive data (messages, PII) may appear in logs. Disable for production.");
}
}

Expand All @@ -130,6 +147,7 @@ public async Task<StreamResponse<TResponse>> MakeRequestAsync<TRequest, TRespons
request.Headers.Add("X-Stream-Client", VersionHeader);

// Add request body if provided
string? requestBodyForLog = null;
if (requestBody != null)
{
// Handle multipart form data for file/image uploads
Expand All @@ -146,9 +164,30 @@ public async Task<StreamResponse<TResponse>> MakeRequestAsync<TRequest, TRespons
// Default to JSON for other request types
var json = JsonSerializer.Serialize(requestBody, _jsonOptions);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
requestBodyForLog = json;
}
}

// Uri parses the same absolute URL BuildUrl just produced, so path/query for
// logging never drift from what is actually sent on the wire.
var uri = new Uri(url);
var logPath = uri.AbsolutePath;
var logQuery = LogRedaction.RedactQuery(uri.Query.TrimStart('?'));

if (_logger != null)
{
if (_logBodies && requestBodyForLog != null)
{
_logger.LogDebug("http.request.sent {Method} {Path} {Query} {Body}",
method, logPath, logQuery, LogRedaction.RedactJsonBody(requestBodyForLog));
}
else
{
_logger.LogDebug("http.request.sent {Method} {Path} {Query}", method, logPath, logQuery);
}
}

var stopwatch = System.Diagnostics.Stopwatch.StartNew();
HttpResponseMessage response;
string responseContent;
try
Expand All @@ -158,7 +197,29 @@ public async Task<StreamResponse<TResponse>> MakeRequestAsync<TRequest, TRespons
}
catch (Exception ex) when (IsTransportException(ex, cancellationToken))
{
throw WrapTransportException(ex, cancellationToken);
stopwatch.Stop();
var transportException = WrapTransportException(ex, cancellationToken);
_logger?.LogError("http.request.failed {Method} {Path} {ErrorType} {DurationMs} {Message}",
method, logPath, transportException.ErrorType, stopwatch.ElapsedMilliseconds,
LogRedaction.RedactMessage(transportException.Message));
throw transportException;
}
stopwatch.Stop();

if (_logger != null)
{
var bodySize = Encoding.UTF8.GetByteCount(responseContent);
if (_logBodies)
{
_logger.LogDebug("http.response.received {Method} {Path} {StatusCode} {BodySize} {DurationMs} {Body}",
method, logPath, (int)response.StatusCode, bodySize, stopwatch.ElapsedMilliseconds,
LogRedaction.RedactJsonBody(responseContent));
}
else
{
_logger.LogDebug("http.response.received {Method} {Path} {StatusCode} {BodySize} {DurationMs}",
method, logPath, (int)response.StatusCode, bodySize, stopwatch.ElapsedMilliseconds);
}
}

if (!response.IsSuccessStatusCode)
Expand All @@ -177,6 +238,7 @@ public async Task<StreamResponse<TResponse>> MakeRequestAsync<TRequest, TRespons
return new StreamResponse<TResponse>();
}

// CHA-2957: near-duplicate send path, not exposed on IClient; intentionally not instrumented with structured logging.
public async Task<StreamResponse<TResponse>> MakeRequestAsyncDebug<TRequest, TResponse>(
string method,
string path,
Expand Down
5 changes: 5 additions & 0 deletions src/ClientBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public class ClientBuilder
private TimeSpan _requestTimeout = TimeSpan.FromSeconds(30);
private System.Net.Http.HttpClient? _httpClient;
private ILogger? _logger;
private bool _logBodies;

/// <summary>
/// Set the API key
Expand Down Expand Up @@ -101,6 +102,9 @@ public ClientBuilder EnvPath(string path)
/// <summary>Opt-in INFO log on construction.</summary>
public ClientBuilder Logger(ILogger logger) { _logger = logger; return this; }

/// <summary>Opt-in body logging (DEBUG events); shallow secret-key redaction still applies. Default false.</summary>
public ClientBuilder LogBodies(bool enabled) { _logBodies = enabled; return this; }

/// <summary>
/// Create a client builder that loads configuration from environment variables
/// </summary>
Expand Down Expand Up @@ -211,6 +215,7 @@ private StreamOptions BuildStreamOptions()
RequestTimeout = _requestTimeout,
HttpClient = _httpClient,
Logger = _logger,
LogBodies = _logBodies,
};
}

Expand Down
60 changes: 60 additions & 0 deletions src/LogRedaction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;

namespace GetStream
{
/// <summary>Redaction helpers for the SDK's structured log events. Shallow by design.</summary>
internal static class LogRedaction
{
internal const string Redacted = "<redacted>";
private static readonly string[] QueryParams = { "api_key", "api_secret", "token" };
private static readonly string[] BodyKeys = { "api_secret", "token", "password" };

// CHA-2957 secret-leak guard: scrubs the same three secret query params wherever
// they appear in a free-form string (e.g. a transport exception's Message), not
// just in a well-formed query string.
private static readonly Regex MessageSecretPattern = new(
@"(api_key|api_secret|token)=[^&\s]*",
RegexOptions.IgnoreCase | RegexOptions.Compiled);

internal static string RedactQuery(string query)
{
if (string.IsNullOrEmpty(query)) return query;
return string.Join("&", query.Split('&').Select(pair =>
{
var idx = pair.IndexOf('=');
if (idx < 0) return pair;
var name = pair[..idx];
return QueryParams.Contains(name.ToLowerInvariant()) ? $"{name}={Redacted}" : pair;
}));
}

internal static string RedactJsonBody(string body)
{
if (string.IsNullOrEmpty(body)) return body;
try
{
if (JsonNode.Parse(body) is not JsonObject obj) return body;
var changed = false;
foreach (var key in BodyKeys)
{
if (obj.ContainsKey(key)) { obj[key] = Redacted; changed = true; }
}
return changed ? obj.ToJsonString() : body;
}
catch (JsonException)
{
return body;
}
}

internal static string RedactMessage(string message)
{
if (string.IsNullOrEmpty(message)) return message;
return MessageSecretPattern.Replace(message, m => $"{m.Groups[1].Value}={Redacted}");
}
}
}
3 changes: 3 additions & 0 deletions src/StreamOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,8 @@ public class StreamOptions

/// <summary>When set, the SDK emits one INFO line on construction.</summary>
public ILogger? Logger { get; set; }

/// <summary>Opt-in body logging on the DEBUG events; known-secret keys stay redacted. Default false.</summary>
public bool LogBodies { get; set; }
}
}
18 changes: 11 additions & 7 deletions tests/ConnectionPoolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,14 @@ public void BaseClient_InfoLog_OnConstruction_WithDefaults()
});
Assert.That(capture.Infos.Count, Is.EqualTo(1), "exactly one INFO line on construction");
var msg = capture.Infos[0];
Assert.That(msg, Does.Contain("max_conns_per_host=5"));
Assert.That(msg, Does.Contain("idle_timeout=00:00:55"));
Assert.That(msg, Does.Contain("connect_timeout=00:00:10"));
Assert.That(msg, Does.Contain("request_timeout=00:00:30"));
Assert.That(msg, Does.Contain("user_http_client=false"));
Assert.That(msg, Does.StartWith("client.initialized"));
Assert.That(msg, Does.Contain("stream.client.max_conns_per_host=5"));
Assert.That(msg, Does.Contain("stream.client.idle_timeout_seconds=55"));
Assert.That(msg, Does.Contain("stream.client.connect_timeout_seconds=10"));
Assert.That(msg, Does.Contain("stream.client.request_timeout_seconds=30"));
Assert.That(msg, Does.Contain("stream.client.user_http_client=false"));
Assert.That(msg, Does.Contain("stream.client.gzip_enabled=true"));
Assert.That(msg, Does.Contain("stream.client.log_bodies=false"));
}

[Test]
Expand All @@ -215,8 +218,9 @@ public void BaseClient_InfoLog_OnConstruction_WithUserHttpClient()
Logger = capture,
});
Assert.That(capture.Infos.Count, Is.EqualTo(1));
Assert.That(capture.Infos[0], Does.Contain("user_http_client=true"));
Assert.That(capture.Infos[0], Does.Contain("5 knobs not applied"));
Assert.That(capture.Infos[0], Does.Contain("stream.client.user_http_client=true"));
Assert.That(capture.Infos[0], Does.Contain("stream.client.gzip_enabled=false"),
"escape hatch: SDK doesn't wire gzip itself, so it can't claim gzip_enabled=true");
}

[Test]
Expand Down
Loading
Loading