Skip to content

Commit 974939d

Browse files
committed
fix(observability): log recovered stream failures
1 parent 670a4e4 commit 974939d

8 files changed

Lines changed: 168 additions & 16 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,23 @@ You can replace `SPRING_CONFIG_ADDITIONAL_LOCATION` at runtime if a different
131131
location is required. `JAVA_TOOL_OPTIONS` is also honored by the JVM for options
132132
such as trust stores, Kerberos diagnostics, or Java agents.
133133

134+
Container logging defaults to `INFO` for this project and `WARN` for Hadoop.
135+
Both can be changed independently, while standard Spring Boot logging variables
136+
remain available:
137+
138+
```bash
139+
docker run --rm -p 8080:8080 \
140+
-e YARN_LOG_API_LOG_LEVEL=DEBUG \
141+
-e YARN_LOG_API_HADOOP_LOG_LEVEL=INFO \
142+
-e LOGGING_LEVEL_REACTOR_NETTY_HTTP_CLIENT=DEBUG \
143+
ghcr.io/openprojectx/yarn-log-api:latest
144+
```
145+
146+
Recovered stream failures are logged with their full stack trace and contextual
147+
application/container information before an `ERROR` or `WARNING` event is sent
148+
to the client. Log contents and SPNEGO cookies are never written by these
149+
diagnostic messages.
150+
134151
The image classpath contains `/etc/hadoop/conf` and `/app/extensions/*`. Mount
135152
extra JARs into the extension directory when adding a filesystem provider,
136153
Spring auto-configuration, metrics exporter, or another runtime integration:

app/src/main/resources/application.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ management:
1818
enabled: true
1919
show-details: never
2020

21+
logging:
22+
level:
23+
org.openprojectx.hadoop.yarn.log.api: ${YARN_LOG_API_LOG_LEVEL:INFO}
24+
org.apache.hadoop: ${YARN_LOG_API_HADOOP_LOG_LEVEL:WARN}
25+
2126
yarn-log-api:
2227
poll-interval: 1s
2328
initial-tail-bytes: 64KB

autoconfigure/src/main/kotlin/org/openprojectx/hadoop/yarn/log/api/autoconfigure/YarnLogSseController.kt

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package org.openprojectx.hadoop.yarn.log.api.autoconfigure
33
import org.openprojectx.hadoop.yarn.log.api.YarnLogEvent
44
import org.openprojectx.hadoop.yarn.log.api.YarnLogStreamRequest
55
import org.openprojectx.hadoop.yarn.log.api.YarnLogStreamService
6+
import org.slf4j.LoggerFactory
67
import org.springframework.http.MediaType
78
import org.springframework.http.codec.ServerSentEvent
89
import org.springframework.web.bind.annotation.GetMapping
@@ -45,11 +46,30 @@ class YarnLogSseController(
4546
tailBytes = tailBytes ?: properties.initialTailBytes.toBytes(),
4647
pollInterval = interval,
4748
)
48-
return service.stream(request).map { event ->
49-
ServerSentEvent.builder(event)
50-
.id(event.sequence.toString())
51-
.event(event.type.name.lowercase())
52-
.build()
53-
}
49+
logger.info(
50+
"Opening YARN log SSE stream: applicationId={}, requester={}, follow={}, logFiles={}, " +
51+
"containers={}, tailBytes={}, pollInterval={}",
52+
request.applicationId,
53+
request.requester ?: "anonymous",
54+
request.follow,
55+
request.logFiles,
56+
request.containerIds,
57+
request.tailBytes,
58+
request.pollInterval,
59+
)
60+
return service.stream(request)
61+
.doOnComplete { logger.info("Completed YARN log SSE stream: applicationId={}", applicationId) }
62+
.doOnCancel { logger.info("Client cancelled YARN log SSE stream: applicationId={}", applicationId) }
63+
.doOnError { error -> logger.error("YARN log SSE stream terminated unexpectedly: applicationId={}", applicationId, error) }
64+
.map { event ->
65+
ServerSentEvent.builder(event)
66+
.id(event.sequence.toString())
67+
.event(event.type.name.lowercase())
68+
.build()
69+
}
70+
}
71+
72+
private companion object {
73+
val logger = LoggerFactory.getLogger(YarnLogSseController::class.java)
5474
}
5575
}

autoconfigure/src/main/kotlin/org/openprojectx/hadoop/yarn/log/api/autoconfigure/YarnLogWebSocketHandler.kt

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package org.openprojectx.hadoop.yarn.log.api.autoconfigure
33
import tools.jackson.databind.ObjectMapper
44
import org.openprojectx.hadoop.yarn.log.api.YarnLogStreamRequest
55
import org.openprojectx.hadoop.yarn.log.api.YarnLogStreamService
6+
import org.slf4j.LoggerFactory
67
import org.springframework.web.reactive.socket.WebSocketHandler
78
import org.springframework.web.reactive.socket.WebSocketSession
89
import reactor.core.publisher.Mono
@@ -23,21 +24,35 @@ class YarnLogWebSocketHandler(
2324
require(interval >= properties.minimumPollInterval) {
2425
"pollInterval must be at least ${properties.minimumPollInterval}"
2526
}
26-
service.stream(
27-
YarnLogStreamRequest(
28-
applicationId = requireNotNull(subscription.applicationId) { "applicationId is required" },
29-
requester = principal.ifEmpty { null },
30-
follow = subscription.follow,
31-
logFiles = subscription.logFiles.toSet().ifEmpty { setOf("stdout", "stderr") },
32-
containerIds = subscription.containers.toSet(),
33-
tailBytes = subscription.tailBytes ?: properties.initialTailBytes.toBytes(),
34-
pollInterval = interval,
35-
),
27+
val request = YarnLogStreamRequest(
28+
applicationId = requireNotNull(subscription.applicationId) { "applicationId is required" },
29+
requester = principal.ifEmpty { null },
30+
follow = subscription.follow,
31+
logFiles = subscription.logFiles.toSet().ifEmpty { setOf("stdout", "stderr") },
32+
containerIds = subscription.containers.toSet(),
33+
tailBytes = subscription.tailBytes ?: properties.initialTailBytes.toBytes(),
34+
pollInterval = interval,
3635
)
36+
logger.info(
37+
"Opening YARN log WebSocket stream: sessionId={}, applicationId={}, requester={}, " +
38+
"follow={}, logFiles={}, containers={}, tailBytes={}, pollInterval={}",
39+
session.id,
40+
request.applicationId,
41+
request.requester ?: "anonymous",
42+
request.follow,
43+
request.logFiles,
44+
request.containerIds,
45+
request.tailBytes,
46+
request.pollInterval,
47+
)
48+
service.stream(request)
3749
}
3850
}
3951
.map { event -> session.textMessage(objectMapper.writeValueAsString(event)) }
4052
return session.send(outgoing)
53+
.doOnSuccess { logger.info("Completed YARN log WebSocket session: sessionId={}", session.id) }
54+
.doOnCancel { logger.info("Client cancelled YARN log WebSocket session: sessionId={}", session.id) }
55+
.doOnError { error -> logger.error("YARN log WebSocket session failed: sessionId={}", session.id, error) }
4156
}
4257

4358
class Subscription {
@@ -48,4 +63,8 @@ class YarnLogWebSocketHandler(
4863
var tailBytes: Long? = null
4964
var pollIntervalMs: Long? = null
5065
}
66+
67+
private companion object {
68+
val logger = LoggerFactory.getLogger(YarnLogWebSocketHandler::class.java)
69+
}
5170
}

core/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ dependencies {
88

99
api("io.projectreactor:reactor-core")
1010
implementation("org.springframework:spring-webflux")
11+
implementation("org.slf4j:slf4j-api")
1112

1213
implementation(libs.hadoopYarnClient) {
1314
exclude(group = "com.sun.jersey")

core/src/main/kotlin/org/openprojectx/hadoop/yarn/log/api/engine/DefaultYarnLogStreamService.kt

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import org.openprojectx.hadoop.yarn.log.api.YarnLogEventType
66
import org.openprojectx.hadoop.yarn.log.api.YarnLogSource
77
import org.openprojectx.hadoop.yarn.log.api.YarnLogStreamRequest
88
import org.openprojectx.hadoop.yarn.log.api.YarnLogStreamService
9+
import org.slf4j.LoggerFactory
910
import reactor.core.publisher.Flux
1011
import reactor.core.publisher.Mono
1112
import reactor.util.retry.Retry
@@ -31,6 +32,14 @@ class DefaultYarnLogStreamService(
3132
Mono.just(session.event(YarnLogEventType.OPEN)),
3233
firstSnapshot(session),
3334
).onErrorResume { error ->
35+
logger.error(
36+
"YARN log stream failed: applicationId={}, follow={}, logFiles={}, containers={}",
37+
request.applicationId,
38+
request.follow,
39+
request.logFiles,
40+
request.containerIds,
41+
error,
42+
)
3443
Flux.just(
3544
session.event(
3645
type = YarnLogEventType.ERROR,
@@ -57,12 +66,24 @@ class DefaultYarnLogStreamService(
5766
private fun cycle(session: Session, snapshot: YarnApplicationSnapshot): Flux<YarnLogEvent> {
5867
val events = mutableListOf<YarnLogEvent>()
5968
if (session.lastApplicationState != snapshot.state.name) {
69+
logger.info(
70+
"YARN application state changed: applicationId={}, state={}",
71+
snapshot.applicationId,
72+
snapshot.state.name,
73+
)
6074
session.lastApplicationState = snapshot.state.name
6175
events += session.event(YarnLogEventType.APPLICATION_STATE, state = snapshot.state.name)
6276
}
6377
snapshot.containers.forEach { container ->
6478
session.containers[container.containerId] = container
6579
if (session.discoveredContainers.add(container.containerId)) {
80+
logger.debug(
81+
"Discovered YARN container: applicationId={}, containerId={}, nodeId={}, state={}",
82+
snapshot.applicationId,
83+
container.containerId,
84+
container.nodeId,
85+
container.state,
86+
)
6687
events += session.event(
6788
type = YarnLogEventType.CONTAINER_DISCOVERED,
6889
container = container,
@@ -108,6 +129,17 @@ class DefaultYarnLogStreamService(
108129
}
109130
return fetchAtWindow(session, container, logType, cursor)
110131
.onErrorResume { error ->
132+
logger.warn(
133+
"Unable to read NodeManager log; the stream will continue: applicationId={}, " +
134+
"containerId={}, nodeId={}, logType={}, cursorOffset={}, windowBytes={}",
135+
session.request.applicationId,
136+
container.containerId,
137+
container.nodeId,
138+
logType,
139+
cursor.offset,
140+
cursor.windowBytes,
141+
error,
142+
)
111143
Mono.just(
112144
session.event(
113145
type = YarnLogEventType.WARNING,
@@ -140,6 +172,15 @@ class DefaultYarnLogStreamService(
140172
}
141173
response.fileLength < cursor.offset -> {
142174
val oldOffset = cursor.offset
175+
logger.warn(
176+
"NodeManager log was truncated: applicationId={}, containerId={}, logType={}, " +
177+
"oldOffset={}, newLength={}",
178+
session.request.applicationId,
179+
container.containerId,
180+
logType,
181+
oldOffset,
182+
response.fileLength,
183+
)
143184
cursor.generation++
144185
cursor.offset = 0
145186
cursor.initialized = false
@@ -162,6 +203,15 @@ class DefaultYarnLogStreamService(
162203
if (expanded <= cursor.windowBytes) {
163204
Mono.error(IllegalStateException("Log growth exceeded the maximum tail window"))
164205
} else {
206+
logger.debug(
207+
"Expanding NodeManager tail window: applicationId={}, containerId={}, logType={}, " +
208+
"oldWindowBytes={}, newWindowBytes={}",
209+
session.request.applicationId,
210+
container.containerId,
211+
logType,
212+
cursor.windowBytes,
213+
expanded,
214+
)
165215
cursor.windowBytes = expanded
166216
fetchAtWindow(session, container, logType, cursor)
167217
}
@@ -216,6 +266,15 @@ class DefaultYarnLogStreamService(
216266
}
217267
return aggregated
218268
.onErrorResume { error ->
269+
logger.warn(
270+
"Unable to read aggregated logs after application completion: applicationId={}, " +
271+
"owner={}, logFiles={}, containers={}",
272+
snapshot.applicationId,
273+
snapshot.owner,
274+
session.request.logFiles,
275+
session.request.containerIds,
276+
error,
277+
)
219278
Flux.just(
220279
session.event(
221280
type = YarnLogEventType.WARNING,
@@ -309,4 +368,8 @@ class DefaultYarnLogStreamService(
309368
message = message,
310369
)
311370
}
371+
372+
private companion object {
373+
val logger = LoggerFactory.getLogger(DefaultYarnLogStreamService::class.java)
374+
}
312375
}

core/src/main/kotlin/org/openprojectx/hadoop/yarn/log/api/engine/HadoopYarnGateway.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package org.openprojectx.hadoop.yarn.log.api.engine
22

33
import org.apache.hadoop.yarn.api.records.ApplicationId
44
import org.apache.hadoop.yarn.client.api.YarnClient
5+
import org.slf4j.LoggerFactory
56
import reactor.core.publisher.Mono
67
import reactor.core.scheduler.Scheduler
78

@@ -38,7 +39,20 @@ class HadoopYarnGateway(
3839
containers = containers,
3940
)
4041
}.subscribeOn(hadoopScheduler)
42+
.doOnSubscribe { logger.debug("Fetching YARN application snapshot: applicationId={}", applicationId) }
43+
.doOnNext { snapshot ->
44+
logger.debug(
45+
"Fetched YARN application snapshot: applicationId={}, state={}, containers={}",
46+
snapshot.applicationId,
47+
snapshot.state.name,
48+
snapshot.containers.size,
49+
)
50+
}
4151

4252
private fun isFinal(state: String): Boolean =
4353
state == "FINISHED" || state == "FAILED" || state == "KILLED"
54+
55+
private companion object {
56+
val logger = LoggerFactory.getLogger(HadoopYarnGateway::class.java)
57+
}
4458
}

core/src/main/kotlin/org/openprojectx/hadoop/yarn/log/api/engine/NodeManagerLogClient.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.openprojectx.hadoop.yarn.log.api.engine
22

33
import org.apache.hadoop.security.authentication.client.AuthenticatedURL
4+
import org.slf4j.LoggerFactory
45
import org.springframework.http.HttpHeaders
56
import org.springframework.http.HttpStatus
67
import org.springframework.http.MediaType
@@ -22,6 +23,13 @@ class NodeManagerLogClient(
2223
windowBytes: Long,
2324
): Mono<NodeManagerLogResponse> {
2425
val uri = buildUri(nodeHttpAddress, containerId, logType, windowBytes)
26+
logger.debug(
27+
"Fetching NodeManager log tail: containerId={}, logType={}, windowBytes={}, uri={}",
28+
containerId,
29+
logType,
30+
windowBytes,
31+
uri,
32+
)
2533
return exchange(uri, allowAuthenticationRetry = true)
2634
.map { parser.parse(it, windowBytes) }
2735
}
@@ -42,6 +50,7 @@ class NodeManagerLogClient(
4250
when {
4351
response.statusCode().is2xxSuccessful -> response.bodyToMono(ByteArray::class.java)
4452
response.statusCode() == HttpStatus.UNAUTHORIZED && allowAuthenticationRetry -> {
53+
logger.warn("NodeManager returned 401; refreshing SPNEGO cookie and retrying: uri={}", uri)
4554
response.releaseBody().then(
4655
Mono.defer {
4756
cookieProvider.invalidate(uri)
@@ -68,4 +77,8 @@ class NodeManagerLogClient(
6877
.encode()
6978
.toUri()
7079
}
80+
81+
private companion object {
82+
val logger = LoggerFactory.getLogger(NodeManagerLogClient::class.java)
83+
}
7184
}

0 commit comments

Comments
 (0)