Lightweight Java client library for sending structured audit events to the PIC-SURE Logging service. Used by PIC-SURE platform components (API, Auth, HPDS, UI, etc.) to emit query, login, access, and error events to a centralized logging service.
- Java 11+ compatible (works with WildFly 17 through Spring Boot 3.x)
- Minimal dependencies — uses
java.net.http.HttpClient(built into JDK 11+), Jackson, and SLF4J - Fire-and-forget — async sends that never throw or block the caller
- Thread-safe — create one instance and share it across your application
- No-op mode — gracefully degrades when the logging service isn't configured
- Environment-based factory — auto-configures from
LOGGING_SERVICE_URLandLOGGING_API_KEY - Secret-safe logging — exception messages are sanitized to avoid leaking API keys in logs
Add to your pom.xml:
<dependency>
<groupId>edu.harvard.dbmi.avillach</groupId>
<artifactId>pic-sure-logging-client</artifactId>
<version>1.0.0</version>
</dependency>Set environment variables and let LoggingClientFactory handle configuration:
export LOGGING_SERVICE_URL=http://pic-sure-logging:80
export LOGGING_API_KEY=your-api-key// Returns a configured client, or no-op if env vars are missing
LoggingClient client = LoggingClientFactory.create("api");
client.send(LoggingEvent.builder("QUERY").action("execute").build());import edu.harvard.dbmi.avillach.logging.*;
// 1. Configure (once at startup)
LoggingClientConfig config = LoggingClientConfig.builder("http://pic-sure-logging:80", apiKey)
.clientType("api") // default client_type for all events
.build();
// 2. Create client (thread-safe, share everywhere)
LoggingClient client = new LoggingClient(config);
// 3. Send events
client.send(LoggingEvent.builder("QUERY").action("execute").build());client.send(LoggingEvent.builder("LOGIN").action("success").build());client.send(LoggingEvent.builder("QUERY")
.action("execute")
.request(RequestInfo.builder()
.method("POST")
.url("/query/sync")
.srcIp(httpRequest.getRemoteAddr())
.status(200)
.duration(elapsed)
.build())
.metadata(Map.of("resourceId", resourceUUID.toString()))
.build());The logging service can extract user claims from a JWT. Pass the Authorization header through so it can attribute events to users:
client.send(
LoggingEvent.builder("QUERY")
.action("execute")
.request(RequestInfo.builder()
.method("POST")
.url("/query/sync")
.srcIp(httpRequest.getRemoteAddr())
.status(200)
.duration(elapsed)
.build())
.build(),
httpRequest.getHeader("Authorization"), // JWT passthrough
httpRequest.getHeader("X-Request-Id") // request correlation
);client.send(LoggingEvent.builder("QUERY")
.action("execute")
.request(RequestInfo.builder()
.method("POST")
.url("/query/sync")
.status(500)
.duration(elapsed)
.build())
.error(Map.of(
"type", e.getClass().getSimpleName(),
"message", e.getMessage()
))
.build());When the logging service isn't available, use the no-op client. It silently discards all events so calling code doesn't need null checks or conditionals:
LoggingClient client = LoggingClient.noOp();
client.isEnabled(); // false
client.send(...); // does nothing| Field | Type | Required | Description |
|---|---|---|---|
event_type |
String | Yes | Event category (e.g. QUERY, LOGIN, ACCESS, ERROR) |
action |
String | No | Specific action (e.g. execute, attempt, success, failure, read) |
client_type |
String | No | Source component (e.g. api, auth, hpds, ui). Falls back to config default. |
request |
RequestInfo | No | HTTP request details (see below) |
metadata |
Map<String, Object> | No | Arbitrary key-value pairs (max 50 keys) |
error |
Map<String, Object> | No | Error details (max 20 keys) |
| Field | Type | Description |
|---|---|---|
request_id |
String | Correlation ID |
method |
String | HTTP method |
url |
String | Request URL/path |
query_string |
String | Query parameters |
src_ip |
String | Client IP address |
dest_ip |
String | Destination IP |
dest_port |
Integer | Destination port |
http_user_agent |
String | User-Agent header |
http_content_type |
String | Content-Type header |
status |
Integer | HTTP response status |
bytes |
Long | Response size in bytes |
duration |
Long | Request duration in ms |
referrer |
String | Referrer URL |
Events are serialized with snake_case field names. Null fields are omitted.
{
"event_type": "QUERY",
"action": "execute",
"client_type": "api",
"request": {
"method": "POST",
"url": "/query/sync",
"src_ip": "192.168.1.1",
"status": 200,
"duration": 150
},
"metadata": {
"resourceId": "uuid-123"
}
}For consumers where the logging-client JAR may not be on the classpath:
LoggingClient client;
try {
client = LoggingClientFactory.create("api");
} catch (NoClassDefFoundError e) {
client = null;
}@ApplicationScoped
public class LoggingClientProducer {
@Produces
@ApplicationScoped
public LoggingClient loggingClient() {
return LoggingClientFactory.create("api");
}
}Then inject it anywhere:
@ApplicationScoped
public class PicsureQueryService {
@Inject
LoggingClient loggingClient;
public Response query(QueryRequest queryRequest, HttpServletRequest httpRequest) {
long start = System.currentTimeMillis();
// ... execute query ...
long duration = System.currentTimeMillis() - start;
loggingClient.send(
LoggingEvent.builder("QUERY")
.action("execute")
.request(RequestInfo.builder()
.method("POST")
.url(httpRequest.getRequestURI())
.srcIp(httpRequest.getRemoteAddr())
.status(200)
.duration(duration)
.build())
.build(),
httpRequest.getHeader("Authorization"),
httpRequest.getHeader("X-Request-Id")
);
return response;
}
}@Configuration
public class LoggingConfig {
@Bean
public LoggingClient loggingClient(
@Value("${logging.service.url:}") String url,
@Value("${logging.service.api-key:}") String key) {
if (url.isEmpty() || key.isEmpty()) {
return LoggingClient.noOp();
}
return new LoggingClient(
LoggingClientConfig.builder(url, key).clientType("auth").build()
);
}
}application.properties:
logging.service.url=http://pic-sure-logging:80
logging.service.api-key=${LOGGING_API_KEY:}Then autowire it:
@Service
public class AuthService {
@Autowired
LoggingClient loggingClient;
public Token login(Credentials creds, HttpServletRequest httpRequest) {
boolean success = authenticate(creds);
loggingClient.send(LoggingEvent.builder("LOGIN")
.action(success ? "success" : "failure")
.request(RequestInfo.builder()
.method("POST")
.url("/login")
.srcIp(httpRequest.getRemoteAddr())
.build())
.metadata(Map.of("email", creds.getEmail()))
.build());
return token;
}
}LoggingClientConfig.builder(baseUrl, apiKey)
.clientType("api") // default client_type for all events
.connectTimeout(Duration.ofSeconds(5)) // TCP connect timeout (default: 5s)
.requestTimeout(Duration.ofSeconds(10)) // HTTP request timeout (default: 10s)
.build();The client enforces the same limits as the server:
event_typeis required (enforced at builder time)metadatamap must not exceed 50 keyserrormap must not exceed 20 keys
- Async fire-and-forget —
send()returns void immediately; the HTTP call runs on the JDK's internal executor - Never throws — serialization errors are caught, connection failures are handled via
CompletableFuture.exceptionally(), all failures logged at WARN - No retries, no circuit breaker — lost events during outages are acceptable; failing user requests is not
- No-op fallback — when env vars are missing, producers return
LoggingClient.noOp()so existing deployments without the logging service aren't affected
mvn clean install # build + test
mvn clean install -DskipTests # build onlyRequires Java 11+.