Classify text client command errors (#8422)

This commit is contained in:
2026-07-10 14:34:56 -07:00
committed by GitHub
parent b142acbf3f
commit c358567a3f
6 changed files with 56 additions and 11 deletions
@@ -18,6 +18,9 @@ object AuthorizationInterceptor {
// Header for warmup/testing (simple username, no JWT required)
private val WARMUP_USER_KEY: Key[String] =
Key.of[String]("X-Warmup-User", Metadata.ASCII_STRING_MARSHALLER)
private val CLIENT_TYPE_KEY: Key[String] =
Key.of[String]("X-Eagle-Client-Type", Metadata.ASCII_STRING_MARSHALLER)
}
/**
@@ -78,9 +81,17 @@ class AuthorizationInterceptor(jwtService: Option[JwtService] = None, skipAuth:
next: ServerCallHandler[ReqT, RespT]
): ServerCall.Listener[ReqT] = {
val methodName = call.getMethodDescriptor.getFullMethodName
val clientType = Option(headers.get(CLIENT_TYPE_KEY)) match {
case Some(AuthorizationUtils.TextClientType) => AuthorizationUtils.TextClientType
case _ => "unknown"
}
def withClientType(context: Context): Context =
context.withValue(AuthorizationUtils.clientTypeCtxKey, clientType)
// Skip auth for public endpoints
if publicEndpoints.contains(methodName) then next.startCall(call, headers)
if publicEndpoints.contains(methodName) then
Contexts.interceptCall(withClientType(Context.current), call, headers, next)
else if skipAuth then {
// When skipAuth is enabled, auto-authenticate all requests as local-dev user
val devContext = AuthorizationUtils.contextWithJwtClaims(
@@ -89,7 +100,7 @@ class AuthorizationInterceptor(jwtService: Option[JwtService] = None, skipAuth:
isAdmin = true, // Give admin privileges for testing
accessToken = ""
)
Contexts.interceptCall(devContext, call, headers, next)
Contexts.interceptCall(withClientType(devContext), call, headers, next)
} else {
// Try JWT Bearer token first
parseBearerToken(headers) match {
@@ -136,7 +147,7 @@ class AuthorizationInterceptor(jwtService: Option[JwtService] = None, skipAuth:
}
effectiveContext match {
case Some(context) => Contexts.interceptCall(context, call, headers, next)
case Some(context) => Contexts.interceptCall(withClientType(context), call, headers, next)
case None => new ServerCall.Listener[ReqT] {}
}
}
@@ -152,13 +163,13 @@ class AuthorizationInterceptor(jwtService: Option[JwtService] = None, skipAuth:
isAdmin = false,
accessToken = ""
)
Contexts.interceptCall(warmupContext, call, headers, next)
Contexts.interceptCall(withClientType(warmupContext), call, headers, next)
case Some(_) =>
// X-Warmup-User from non-local network - ignore it, treat as unauthenticated
next.startCall(call, headers)
Contexts.interceptCall(withClientType(Context.current), call, headers, next)
case None =>
// No auth at all - let endpoint handle unauthenticated request
next.startCall(call, headers)
Contexts.interceptCall(withClientType(Context.current), call, headers, next)
}
}
}
@@ -4,6 +4,8 @@ import io.grpc.Context
import net.eagle0.eagle.UserId
object AuthorizationUtils {
val TextClientType = "text"
// Username key - set by JWT auth for backwards compatibility with game management code
val userNameCtxKey: Context.Key[String] = Context.key("userName")
@@ -12,6 +14,7 @@ object AuthorizationUtils {
val displayNameCtxKey: Context.Key[String] = Context.key("displayName")
val isAdminCtxKey: Context.Key[Boolean] = Context.keyWithDefault("isAdmin", false)
val accessTokenCtxKey: Context.Key[String] = Context.key("accessToken")
val clientTypeCtxKey: Context.Key[String] = Context.keyWithDefault("clientType", "unknown")
// Accessor for username (set by JWT auth)
def userName: String = userNameCtxKey.get(Context.current)
@@ -31,6 +34,8 @@ object AuthorizationUtils {
def accessToken: Option[String] = Option(accessTokenCtxKey.get(Context.current))
def clientType: String = clientTypeCtxKey.get(Context.current)
// Context builder for JWT auth
def contextWithJwtClaims(
userId: String,
@@ -315,6 +315,7 @@ class EagleServiceImpl(
case UpdateStreamRequest.RequestDetails.PostCommandRequest(
request
) =>
val clientType = AuthorizationUtils.clientType
postCommand(request).map { postCommandResponse =>
responseObserver.onNext(
UpdateStreamResponse(
@@ -333,10 +334,14 @@ class EagleServiceImpl(
case e: Exception =>
val commandDesc = describeCommand(request)
SimpleTimedLogger.printLogger.logLine(
s"Error processing command for ${responseObserver.userName} ($commandDesc): ${e.getMessage}"
s"Command error clientType=$clientType user=${responseObserver.userName} ($commandDesc): ${e.getMessage}"
)
e.printStackTrace()
Sentry.captureException(e)
if EagleServiceImpl.shouldReportCommandException(clientType, e) then
Sentry.withScope { scope =>
scope.setTag("client_type", clientType)
Sentry.captureException(e): Unit
}
responseObserver.onNext(
UpdateStreamResponse(
responseDetails = UpdateStreamResponse.ResponseDetails.PostCommandResponse(
@@ -695,6 +700,9 @@ object EagleServiceImpl {
.get("EAGLE_LOG_HEARTBEATS")
.exists(value => EnabledEnvValues.contains(value.trim.toLowerCase))
private[service] def shouldReportCommandException(clientType: String, exception: Exception): Boolean =
clientType != AuthorizationUtils.TextClientType || !exception.isInstanceOf[EagleClientException]
private[service] def tutorialStartPhaseForCreateGame(request: CreateGameRequest): Option[TutorialPhase] =
if request.tutorialStartPhase != TutorialPhaseProto.TUTORIAL_PHASE_UNKNOWN then
Some(TutorialPhaseConverter.fromProto(request.tutorialStartPhase))
@@ -33,6 +33,8 @@ object EagleTextClient {
Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER)
private val WarmupUserKey: Metadata.Key[String] =
Metadata.Key.of("X-Warmup-User", Metadata.ASCII_STRING_MARSHALLER)
private val ClientTypeKey: Metadata.Key[String] =
Metadata.Key.of("X-Eagle-Client-Type", Metadata.ASCII_STRING_MARSHALLER)
def main(args: Array[String]): Unit = {
val config = Config.fromArgs(args.toVector)
@@ -106,14 +108,13 @@ final class EagleTextClient(config: Config) {
private val baseStub: EagleStub = EagleGrpc.stub(channel)
private val stub: EagleStub = {
val metadata = new Metadata()
metadata.put(EagleTextClient.ClientTypeKey, "text")
config.bearerToken.foreach(token =>
metadata.put(EagleTextClient.AuthorizationKey, s"Bearer ${token.stripPrefix("Bearer ")}")
)
config.warmupUser.foreach(user => metadata.put(EagleTextClient.WarmupUserKey, user))
if config.bearerToken.isDefined || config.warmupUser.isDefined then
baseStub.withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata))
else baseStub
baseStub.withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata))
}
private val state = new ClientState
@@ -35,6 +35,7 @@ scala_test(
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:eagle_scala_grpc",
"//src/main/protobuf/net/eagle0/eagle/common:tutorial_phase_scala_proto",
"//src/main/scala/net/eagle0/eagle/library:eagle_client_exception",
"//src/main/scala/net/eagle0/eagle/model/state:tutorial_phase",
"//src/main/scala/net/eagle0/eagle/service",
],
@@ -2,12 +2,31 @@ package net.eagle0.eagle.service
import net.eagle0.eagle.api.eagle.CreateGameRequest
import net.eagle0.eagle.common.tutorial_phase.TutorialPhase as TutorialPhaseProto
import net.eagle0.eagle.library.EagleClientException
import net.eagle0.eagle.model.state.TutorialPhase
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class EagleServiceImplTest extends AnyFlatSpec with Matchers {
"shouldReportCommandException" should "suppress expected command errors from the text client" in {
EagleServiceImpl.shouldReportCommandException(
"text",
EagleClientException("invalid command")
) shouldBe false
}
it should "report unexpected errors from the text client and all errors from unmarked clients" in {
EagleServiceImpl.shouldReportCommandException(
"text",
RuntimeException("unexpected failure")
) shouldBe true
EagleServiceImpl.shouldReportCommandException(
"unknown",
EagleClientException("invalid command")
) shouldBe true
}
"tutorialStartPhaseForCreateGame" should "start tutorial requests at the opening battle by default" in {
EagleServiceImpl.tutorialStartPhaseForCreateGame(
CreateGameRequest(isTutorial = true)