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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,31 @@ call.getOrCreate(
> **Note:** When constructing models, always use the **builder pattern** (e.g. `UserRequest.builder().id("id").build()`).
> While some generated models expose positional constructors (for example via Lombok's `@AllArgsConstructor`), their parameter order is not part of the public API and may change between releases; using positional constructors is therefore strongly discouraged and may break across SDK updates.

### Logging

The SDK emits structured log events through [SLF4J](https://www.slf4j.org/) (dependency `org.slf4j:slf4j-api`). Inject your own SLF4J `Logger` via `StreamClientOptions.setLogger(...)`. When no logger is injected the SDK logs to a no-op logger, so nothing is emitted unless you opt in. The SDK never changes the logger's level; that stays entirely under your control through your SLF4J binding.

```java
org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger("io.getstream");
var options = new StreamClientOptions().setLogger(logger);
var client = new StreamSDKClient("apiKey", "apiSecret", options);
```

Four events are emitted:

| Event | Level | When |
| ----- | ----- | ---- |
| `client.initialized` | INFO | once, at client construction (SDK name/version and the effective client config) |
| `http.request.sent` | DEBUG | before each request (method, path, query) |
| `http.response.received` | DEBUG | after any response, including 4xx/5xx (status code, body size, duration) |
| `http.request.failed` | ERROR | transport failure only, when no HTTP response was received (error type, message, duration) |

Redaction is mandatory and cannot be disabled: query values for `api_key`, `api_secret` and `token` are replaced with `<redacted>`, and the top-level JSON body keys `api_secret`, `token` and `password` are redacted. The events never log request/response headers.

Request and response bodies are **not** logged by default. Call `StreamClientOptions.setLogBodies(true)` to opt in (secret body keys are still redacted); doing so emits a one-time warning at construction. Do not enable body logging in production unless you accept the risk of logging sensitive payloads.

> **Deprecated:** the older `HttpLoggingInterceptor` is deprecated in favour of these SLF4J events. It is kept for backward compatibility and now redacts secret headers and secret body keys in its own output.

## Development

To run tests, create the `local.properties` file using the `local.properties.example` and adjust it to have valid API credentials:
Expand Down
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dependencies {

implementation(platform("com.squareup.okhttp3:okhttp-bom:4.12.0"))
implementation("com.squareup.okhttp3:okhttp")
implementation("org.slf4j:slf4j-api:2.0.13")
implementation("com.fasterxml.jackson.core:jackson-databind:2.18.2")
implementation("com.fasterxml.jackson.core:jackson-annotations:2.18.2")
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@
*
* <p>The format of the logs created by this class should not be considered stable and may change
* slightly between releases. If you need a stable logging format, use your own interceptor.
*
* @deprecated Superseded by the SLF4J structured log events emitted by the SDK (inject a logger via
* {@code StreamClientOptions.setLogger}). Kept for backward compatibility. Secret headers,
* secret body keys, and secret URL query values ({@code api_key}/{@code api_secret}/{@code
* token}) are now redacted in its output.
*/
@Deprecated
public final class HttpLoggingInterceptor implements Interceptor {
private static final Charset UTF8 = StandardCharsets.UTF_8;

Expand Down Expand Up @@ -181,7 +187,7 @@ public Response intercept(Chain chain) throws IOException {
"--> "
+ request.method()
+ ' '
+ request.url()
+ LogRedaction.redactUrl(request.url())
+ (connection != null ? " " + connection.protocol() : "");
if (!logHeaders && hasRequestBody) {
requestStartMessage += " (" + requestBody.contentLength() + "-byte body)";
Expand All @@ -205,7 +211,7 @@ public Response intercept(Chain chain) throws IOException {
String name = headers.name(i);
// Skip headers from the request body as they are explicitly logged above.
if (!"Content-Type".equalsIgnoreCase(name) && !"Content-Length".equalsIgnoreCase(name)) {
logger.log(name + ": " + headers.value(i));
logger.log(name + ": " + LogRedaction.redactHeaderValue(name, headers.value(i)));
}
}
}
Expand All @@ -228,7 +234,7 @@ public Response intercept(Chain chain) throws IOException {

logger.log("Request body:");
if (isPlaintext(buffer)) {
logger.log(buffer.readString(charset));
logger.log(LogRedaction.redactJsonBody(buffer.readString(charset)));
logger.log(
"--> END " + request.method() + " (" + requestBody.contentLength() + "-byte body)");
} else {
Expand Down Expand Up @@ -259,7 +265,7 @@ public Response intercept(Chain chain) throws IOException {
+ response.code()
+ (response.message().isEmpty() ? "" : ' ' + response.message())
+ ' '
+ response.request().url()
+ LogRedaction.redactUrl(response.request().url())
+ " ("
+ tookMs
+ "ms"
Expand All @@ -269,7 +275,10 @@ public Response intercept(Chain chain) throws IOException {
if (logHeaders) {
Headers headers = response.headers();
for (int i = 0, count = headers.size(); i < count; i++) {
logger.log(headers.name(i) + ": " + headers.value(i));
logger.log(
headers.name(i)
+ ": "
+ LogRedaction.redactHeaderValue(headers.name(i), headers.value(i)));
}
}
if (!logResponseBody || response.body() == null) {
Expand Down Expand Up @@ -298,7 +307,7 @@ public Response intercept(Chain chain) throws IOException {

if (contentLength != 0) {
logger.log("Response body:");
logger.log(buffer.clone().readString(charset));
logger.log(LogRedaction.redactJsonBody(buffer.clone().readString(charset)));
}

logger.log("<-- END HTTP (" + buffer.size() + "-byte body)");
Expand Down
70 changes: 70 additions & 0 deletions src/main/java/io/getstream/services/framework/LogRedaction.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package io.getstream.services.framework;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.Set;
import okhttp3.HttpUrl;

/** Redaction helpers for the SDK's structured log events. Shallow by design. */
final class LogRedaction {
static final String REDACTED = "<redacted>";
private static final Set<String> QUERY_PARAMS = Set.of("api_key", "api_secret", "token");
private static final Set<String> BODY_KEYS = Set.of("api_secret", "token", "password");
private static final ObjectMapper MAPPER = new ObjectMapper();

private LogRedaction() {}

static String redactQuery(HttpUrl url) {
if (url.querySize() == 0) return "";
var b = new StringBuilder();
for (int i = 0; i < url.querySize(); i++) {
if (i > 0) b.append('&');
String name = url.queryParameterName(i);
String value =
QUERY_PARAMS.contains(name.toLowerCase()) ? REDACTED : url.queryParameterValue(i);
b.append(name).append('=').append(value);
}
return b.toString();
}

/**
* Full URL as a string with secret query values redacted, scheme/host/path preserved. Used by the
* deprecated {@link HttpLoggingInterceptor}, whose response-summary line logs the final request
* URL after a downstream interceptor may have appended {@code api_key}.
*/
static String redactUrl(HttpUrl url) {
String base = url.newBuilder().query(null).build().toString();
String query = redactQuery(url);
return query.isEmpty() ? base : base + "?" + query;
}

static boolean isSecretHeader(String name) {
String n = name.toLowerCase();
return n.equals("authorization")
|| n.endsWith("-token")
|| n.endsWith("-secret")
|| n.endsWith("-key");
}

static String redactHeaderValue(String name, String value) {
return isSecretHeader(name) ? REDACTED : value;
}

static String redactJsonBody(String body) {
if (body == null || body.isEmpty()) return body;
try {
var node = MAPPER.readTree(body);
if (!(node instanceof ObjectNode obj)) return body;
boolean changed = false;
for (String key : BODY_KEYS) {
if (obj.has(key)) {
obj.put(key, REDACTED);
changed = true;
}
}
return changed ? MAPPER.writeValueAsString(obj) : body;
} catch (Exception e) {
return body;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import okhttp3.OkHttpClient;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.helpers.NOPLogger;

/**
* Tunables for the SDK's HTTP transport / connection pool. Per CHA-2956. Defaults: 5 conns/host,
Expand All @@ -21,6 +23,8 @@ public class StreamClientOptions {
@NotNull private Duration connectTimeout = DEFAULT_CONNECT_TIMEOUT;
@NotNull private Duration requestTimeout = DEFAULT_REQUEST_TIMEOUT;
@Nullable private OkHttpClient httpClient;
@Nullable private Logger logger;
private boolean logBodies = false;

public StreamClientOptions setMaxConnsPerHost(int n) {
if (n <= 0) throw new IllegalArgumentException("maxConnsPerHost must be > 0, got " + n);
Expand Down Expand Up @@ -55,6 +59,24 @@ public StreamClientOptions setHttpClient(@Nullable OkHttpClient client) {
return this;
}

/**
* Inject an SLF4J {@link Logger} for the SDK's structured log events. When unset, the SDK logs to
* a no-op logger. The SDK never sets the logger's level.
*/
public StreamClientOptions setLogger(@Nullable Logger logger) {
this.logger = logger;
return this;
}

/**
* Opt in to logging HTTP request/response bodies on the debug events. Secret body keys are still
* redacted. Off by default; enabling it emits a one-time warning at client construction.
*/
public StreamClientOptions setLogBodies(boolean logBodies) {
this.logBodies = logBodies;
return this;
}

public int getMaxConnsPerHost() {
return maxConnsPerHost;
}
Expand Down Expand Up @@ -82,4 +104,18 @@ public OkHttpClient getHttpClient() {
public boolean hasUserHttpClient() {
return httpClient != null;
}

@Nullable
public Logger getLogger() {
return logger;
}

@NotNull
public Logger getLoggerOrNop() {
return logger != null ? logger : NOPLogger.NOP_LOGGER;
}

public boolean getLogBodies() {
return logBodies;
}
}
61 changes: 47 additions & 14 deletions src/main/java/io/getstream/services/framework/StreamHTTPClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,16 @@
import java.time.Duration;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;
import javax.crypto.spec.SecretKeySpec;
import okhttp3.ConnectionPool;
import okhttp3.Dispatcher;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;

public class StreamHTTPClient {
private static final Logger LOG = Logger.getLogger(StreamHTTPClient.class.getName());

public static final String API_KEY_PROP_NAME = "io.getstream.apiKey";
public static final String API_SECRET_PROP_NAME = "io.getstream.apiSecret";
public static final String API_TIMEOUT_PROP_NAME = "io.getstream.timeout";
Expand Down Expand Up @@ -98,6 +96,14 @@ public StreamHTTPClient() {
}

public StreamHTTPClient(Properties properties) throws IllegalArgumentException {
this(properties, new StreamClientOptions());
}

public StreamHTTPClient(Properties properties, @NotNull StreamClientOptions options)
throws IllegalArgumentException {
// Set options before reading env/properties so env overrides (timeout, connection max-age)
// fold into them and the caller's injected logger is used for the client.initialized event.
this.options = options;
readPropertiesAndEnv(properties);

if (apiKey == null || apiKey.isEmpty()) {
Expand Down Expand Up @@ -152,6 +158,22 @@ public String getBaseUrl() {
return baseUrl;
}

// Why: construction-time / test-support only (points a client at MockWebServer). Package-private
// to keep it off the public API; not safe for concurrent post-construction mutation.
void setBaseUrl(@NotNull String baseUrl) {
this.baseUrl = baseUrl;
}

/** The SLF4J logger for structured events (a no-op logger when none was injected). */
@NotNull
public Logger getLogger() {
return options.getLoggerOrNop();
}

public boolean getLogBodies() {
return options.getLogBodies();
}

private void setCredetials(@NotNull String apiKey, @NotNull String apiSecret) {
this.apiKey = apiKey;
this.apiSecret = apiSecret;
Expand All @@ -176,17 +198,26 @@ private OkHttpClient.Builder defaultHttpClientBuilder() {
}

private void logEffectiveConfig() {
if (options.hasUserHttpClient()) {
LOG.info("connection pool: user_http_client=true (4 knobs not applied)");
} else {
LOG.info(
String.format(
"connection pool: max_conns_per_host=%d idle_timeout=%s connect_timeout=%s"
+ " request_timeout=%s user_http_client=false",
options.getMaxConnsPerHost(),
options.getIdleTimeout(),
options.getConnectTimeout(),
options.getRequestTimeout()));
Logger logger = getLogger();
logger.info(
"client.initialized stream.sdk.name=stream-sdk-java stream.sdk.version={}"
+ " stream.client.max_conns_per_host={} stream.client.idle_timeout_seconds={}"
+ " stream.client.connect_timeout_seconds={} stream.client.request_timeout_seconds={}"
+ " stream.client.gzip_enabled={} stream.client.user_http_client={}"
+ " stream.client.log_bodies={}",
sdkVersion,
options.getMaxConnsPerHost(),
options.getIdleTimeout().toSeconds(),
options.getConnectTimeout().toSeconds(),
options.getRequestTimeout().toSeconds(),
true,
options.hasUserHttpClient(),
options.getLogBodies());
if (options.getLogBodies()) {
logger.warn(
"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 Down Expand Up @@ -242,10 +273,12 @@ private void readPropertiesAndEnv(Properties properties) {
}
}

@SuppressWarnings("deprecation")
private @NotNull HttpLoggingInterceptor.Level getLogLevel() {
return HttpLoggingInterceptor.Level.valueOf(logLevel);
}

@SuppressWarnings("deprecation")
private OkHttpClient buildHTTPClient(String jwtToken, OkHttpClient.Builder httpClient) {
httpClient.interceptors().clear();

Expand Down
Loading
Loading