Skip to content

Commit 3a5939f

Browse files
Ma77Ballclaude
andcommitted
feat(observability): logs end-to-end (gateway + UI panel)
Add the first complete signal: log search through the query gateway plus the UI panel that drives it. Builds on the gateway core and dashboard shell from PR 4. This is PR 5 of the observability stack. Gateway (amber): - ResponseParsers turns the log store's responses into the typed results the UI consumes (parseLogs, parseLogSources), applying per-field redaction so sensitive values never reach the client. - builders and dtos gain the logs query builder and its validated request objects (time window, service, workflow, computing unit, execution, level, free text, page size). - ObservabilityResources exposes the log-search and source-facets endpoints, registered in TexeraWebApplication. Logs panel (frontend): - A logs panel under the observability page with time-window, service, workflow, computing-unit, execution, level, and free-text filters, server-side paging, and sources-backed autofill. - observability-prefs persists the panel's filter choices, and the observability component hosts the panel behind its reachability gate. Tests: backend specs for the logs query builder and the response parsers, plus the frontend logs-panel and service specs. sbt scalafmtCheckAll is clean. Stacked on PR 4 (obs/pr4/gateway-core). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 681c046 commit 3a5939f

15 files changed

Lines changed: 2547 additions & 5 deletions

File tree

‎amber/src/main/scala/org/apache/texera/web/TexeraWebApplication.scala‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ import org.apache.texera.auth.SessionUser
3333
import org.apache.texera.dao.SqlServer
3434
import org.apache.texera.observability.OtelInit
3535
import org.apache.texera.web.auth.JwtAuth.setupJwtAuth
36-
import org.apache.texera.web.observability.gateway.{GatewayContext, ObservabilityHealthResource}
36+
import org.apache.texera.web.observability.gateway.{
37+
GatewayContext,
38+
LogsResource,
39+
ObservabilityHealthResource
40+
}
3741
import org.apache.texera.web.resource._
3842
import org.apache.texera.web.resource.auth.{
3943
AppleAuthResource,
@@ -179,6 +183,7 @@ class TexeraWebApplication
179183
// respective PRs (obs/pr10..13).
180184
logger.debug("Registering observability gateway resources")
181185
val obsCtx = GatewayContext.default()
186+
environment.jersey.register(new LogsResource(obsCtx))
182187
environment.jersey.register(new ObservabilityHealthResource(obsCtx))
183188

184189
AuthResource.createAdminUser()

‎amber/src/main/scala/org/apache/texera/web/observability/gateway/ObservabilityResources.scala‎

Lines changed: 287 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,18 @@ package org.apache.texera.web.observability.gateway
2121

2222
import com.typesafe.scalalogging.LazyLogging
2323
import io.dropwizard.auth.Auth
24+
import javax.annotation.security.RolesAllowed
2425
import javax.ws.rs._
25-
import javax.ws.rs.core.{MediaType, Response}
26+
import javax.ws.rs.core.{Context, MediaType, Response}
2627
import javax.servlet.http.HttpServletRequest
2728
import org.apache.texera.auth.SessionUser
29+
import org.apache.texera.dao.SqlServer
30+
import org.apache.texera.dao.jooq.generated.tables.daos.UserDao
2831
import org.apache.texera.web.observability.gateway.dtos._
2932

33+
import scala.jdk.CollectionConverters._
34+
import scala.util.Try
35+
3036
/**
3137
* Dropwizard resources for the observability gateway.
3238
*
@@ -99,6 +105,7 @@ private[gateway] object Preflight extends LazyLogging {
99105
class ObservabilityHealthResource(ctx: GatewayContext) extends LazyLogging {
100106

101107
@GET
108+
@RolesAllowed(Array("REGULAR", "ADMIN"))
102109
def health(@Auth user: SessionUser): Response = {
103110
// Light-touch reachability — used by the dashboard to render
104111
// "Disabled" / "Unreachable" panels. No backend query; just a
@@ -129,3 +136,282 @@ class ObservabilityHealthResource(ctx: GatewayContext) extends LazyLogging {
129136
.isRight
130137
}
131138
}
139+
@Path("/observability/logs")
140+
@Produces(Array(MediaType.APPLICATION_JSON))
141+
@Consumes(Array(MediaType.APPLICATION_JSON))
142+
class LogsResource(ctx: GatewayContext) extends LazyLogging {
143+
144+
@POST
145+
@Path("/search")
146+
@RolesAllowed(Array("ADMIN"))
147+
def search(
148+
request: RawLogsSearchRequest,
149+
@Auth user: SessionUser,
150+
@Context httpReq: HttpServletRequest
151+
): Response = {
152+
logger.debug(s"logs search requested by user ${user.getUid}")
153+
Preflight.run(ctx, user, httpReq) match {
154+
case Left(err) => Respond.err(err)
155+
case Right(scope) =>
156+
validate(request) match {
157+
case Invalid(err) => Respond.err(err)
158+
case Valid(valid) =>
159+
// Tenancy assertion: a caller-supplied workflowId must
160+
// be in the resolved allow-set. No widening possible.
161+
if (!ctx.scopeResolver.assertWorkflowAllowed(scope, valid.workflowId)) {
162+
logger.warn(
163+
s"logs search DENIED: user ${user.getUid} requested workflow " +
164+
s"${valid.workflowId.getOrElse("<none>")} outside their allowed scope"
165+
)
166+
Respond.err(GatewayError.Forbidden)
167+
} else
168+
runLogsQuery(valid, scope, httpReq, user)
169+
}
170+
}
171+
}
172+
173+
private def runLogsQuery(
174+
req: ValidatedLogsRequest,
175+
scope: GatewayScope,
176+
httpReq: HttpServletRequest,
177+
user: SessionUser
178+
): Response = {
179+
val query = LogsQLBuilder.build(req, scope)
180+
logger.debug(
181+
s"logs query for user ${user.getUid}: workflow=${req.workflowId.getOrElse("*")} " +
182+
s"level=${req.level.map(_.toString).getOrElse("*")} pageSize=${req.pageSize.value} — LogsQL: $query"
183+
)
184+
// VictoriaLogs defaults the `start`/`end` URL params to "the last
185+
// 5 minutes" when omitted, which silently shrinks every search
186+
// regardless of what the user picked in the time picker. We pass
187+
// both explicitly so the picked window actually drives the query.
188+
// The seconds form is what /select/logsql/query expects.
189+
val startSec = req.window.from.getEpochSecond
190+
val endSec = req.window.to.getEpochSecond
191+
val path =
192+
s"/select/logsql/query?query=${java.net.URLEncoder.encode(query, "UTF-8")}" +
193+
s"&start=$startSec&end=$endSec"
194+
ctx.logsClient.get(path, scope, "logs") match {
195+
case Left(err) => Respond.err(err)
196+
case Right(resp) if !resp.isOk =>
197+
Respond.err(GatewayError.BackendError("logs", resp.status))
198+
case Right(resp) =>
199+
// Pass the RAW NDJSON body. LogSanitizer strips C0 control
200+
// characters including '\n' (intentionally — prevents log
201+
// forging in OTel-bridged records); applying it to the
202+
// whole body would collapse every line of VictoriaLogs's
203+
// NDJSON output into a single line and break parsing.
204+
// ResponseParsers.parseLogs redacts secrets per-field
205+
// after splitting lines.
206+
ResponseParsers.parseLogs(resp.body, req.pageSize.value) match {
207+
case Left(err) => Respond.err(err)
208+
case Right(parsed) =>
209+
// If the response is exactly pageSize entries, more pages
210+
// probably exist — surface the next offset as the cursor.
211+
// (A short page means we definitely reached the end.) We
212+
// can't reliably distinguish "exactly fits the page" from
213+
// "more available" without a server total; assuming more
214+
// is the conservative UX choice.
215+
val pageFull = parsed.entries.size >= req.pageSize.value
216+
val withCursor =
217+
if (pageFull)
218+
parsed.copy(nextCursor = Some((req.offset + req.pageSize.value).toString))
219+
else parsed
220+
logger.info(
221+
s"logs search ok for user ${user.getUid}: ${withCursor.total} entr(ies)" +
222+
(if (pageFull) " (more pages available)" else "")
223+
)
224+
AuditLogger.record(
225+
AuditLogger.Entry(
226+
userId = user.getUid.longValue(),
227+
remoteIp = Option(httpReq.getRemoteAddr).getOrElse("unknown"),
228+
endpoint = "/observability/logs/search",
229+
signal = "logs",
230+
scope = scope,
231+
query = query,
232+
fromMs = req.window.from.toEpochMilli,
233+
toMs = req.window.to.toEpochMilli,
234+
hits = withCursor.total
235+
)
236+
)
237+
Respond.json(withCursor)
238+
}
239+
}
240+
}
241+
242+
/**
243+
* Distinct filter values currently present in the logs store —
244+
* powers the UI's autofill dropdowns (service / workflow / CU).
245+
*
246+
* GET because it has no body and no side effects, and so the
247+
* frontend can cache it. The handler still goes through the
248+
* standard preflight (rate limit + scope resolution) so an
249+
* unauthenticated caller can't enumerate the workflow ids of
250+
* other tenants.
251+
*/
252+
@GET
253+
@Path("/sources")
254+
@RolesAllowed(Array("ADMIN"))
255+
def sources(
256+
@Auth user: SessionUser,
257+
@Context httpReq: HttpServletRequest
258+
): Response = {
259+
Preflight.run(ctx, user, httpReq) match {
260+
case Left(err) => Respond.err(err)
261+
case Right(scope) =>
262+
// 7-day window is plenty for autofill. Services come from
263+
// /streams (service / service.name ARE stream fields). The
264+
// texera.*.id fields are record fields, NOT stream labels, on
265+
// OTel-bridged JVM logs, so /streams never lists them; pull each
266+
// id field's distinct values from /field_values instead.
267+
val path = "/select/logsql/streams?query=*&start=7d"
268+
ctx.logsClient.get(path, scope, "logs") match {
269+
case Left(err) => Respond.err(err)
270+
case Right(resp) if !resp.isOk =>
271+
Respond.err(GatewayError.BackendError("logs", resp.status))
272+
case Right(resp) =>
273+
ResponseParsers.parseLogSources(resp.body, scope.allowedWorkflowIds) match {
274+
case Left(err) => Respond.err(err)
275+
case Right(fromStreams) =>
276+
// Best-effort: a field_values hiccup must not blank the
277+
// whole autofill, so failures fall back to empty.
278+
// 30-day window: ids like computing_unit.id only appear
279+
// in workflow-execution logs and are sparse, so a 7-day
280+
// window often leaves their dropdown empty.
281+
def idValues(field: String): Seq[Long] =
282+
ctx.logsClient.get(
283+
s"/select/logsql/field_values?query=*&field=$field&start=30d",
284+
scope,
285+
"logs"
286+
) match {
287+
case Right(r) if r.isOk =>
288+
ResponseParsers.parseFieldValueLongs(r.body).getOrElse(Seq.empty)
289+
case _ => Seq.empty
290+
}
291+
val workflowIds =
292+
idValues("texera.workflow.id").filter(scope.allowedWorkflowIds.contains)
293+
val cuIds = idValues("texera.computing_unit.id")
294+
val finalUserIds =
295+
(fromStreams.userIds ++ idValues("texera.user.id")).distinct.sorted
296+
val parsed = fromStreams.copy(
297+
workflowIds = (fromStreams.workflowIds ++ workflowIds).distinct.sorted,
298+
computingUnitIds = (fromStreams.computingUnitIds ++ cuIds).distinct.sorted,
299+
userIds = finalUserIds,
300+
userNames = resolveUserNames(finalUserIds)
301+
)
302+
logger.debug(
303+
s"log sources for user ${user.getUid}: ${parsed.services.size} service(s), " +
304+
s"${parsed.workflowIds.size} workflow(s), ${parsed.computingUnitIds.size} CU(s), " +
305+
s"${parsed.userIds.size} user(s)"
306+
)
307+
Respond.json(parsed)
308+
}
309+
}
310+
}
311+
}
312+
313+
/** Force an `Option[Long]` from Jackson into a real boxed Long.
314+
* Jackson Scala deserializes a JSON number that fits in 32 bits
315+
* as `java.lang.Integer` regardless of the declared type
316+
* parameter (which is erased on the JVM). When downstream code
317+
* uses the value, Scala's runtime unbox throws ClassCastException.
318+
*
319+
* Implementation: we MUST go through `Option[Any]` before calling
320+
* `.map` — `Option[Long].map` is specialized to `JFunction1$mcJJ$sp`
321+
* which unboxes the value to Long via BoxesRunTime BEFORE the
322+
* closure body runs, so any inline asInstanceOf there is too late.
323+
* Casting to `Option[Any]` selects the generic, non-specialized
324+
* apply path, which keeps the value boxed and hands it to the
325+
* closure as-is.
326+
*/
327+
private def normaliseLong(opt: Option[Long]): Option[Long] = {
328+
val anyOpt: Option[Any] = opt.asInstanceOf[Option[Any]]
329+
anyOpt.map {
330+
case n: java.lang.Number => n.longValue()
331+
case other => other.toString.toLong
332+
}
333+
}
334+
335+
/** Resolve log user ids to display names via the user table, for the
336+
* autofill dropdown. Best-effort: a single batched query, and any
337+
* failure (or unresolved id) just yields no name so the UI falls
338+
* back to "User #id".
339+
*/
340+
private def resolveUserNames(userIds: Seq[Long]): Map[Long, String] = {
341+
if (userIds.isEmpty) return Map.empty
342+
Try {
343+
val dao = new UserDao(SqlServer.getInstance().createDSLContext().configuration())
344+
val boxed = userIds.map(id => Integer.valueOf(id.toInt))
345+
dao
346+
.fetchByUid(boxed: _*)
347+
.asScala
348+
.iterator
349+
.flatMap { u =>
350+
Option(u.getUid).flatMap(uid => Option(u.getName).map(name => uid.longValue() -> name))
351+
}
352+
.toMap
353+
}.getOrElse(Map.empty)
354+
}
355+
356+
private def validate(raw: RawLogsSearchRequest): ValidationResult[ValidatedLogsRequest] = {
357+
TimeWindow.validate(raw.fromMs, raw.toMs) match {
358+
case Invalid(e) => Invalid(e)
359+
case Valid(window) =>
360+
PageSize.validate(raw.pageSize) match {
361+
case Invalid(e) => Invalid(e)
362+
case Valid(pageSize) =>
363+
val level: Option[LogLevel] = raw.level.flatMap(LogLevel.parse)
364+
if (raw.level.isDefined && level.isEmpty) Invalid(GatewayError.BadLevel)
365+
else
366+
FreeText.validate(raw.query) match {
367+
case Invalid(e) => Invalid(e)
368+
case Valid(freeText) =>
369+
ServiceName.validateMany(raw.services) match {
370+
case Invalid(e) => Invalid(e)
371+
case Valid(services) =>
372+
// Sort: parse closed enum or fall back to default.
373+
val sortOrErr = raw.sort match {
374+
case None => Right(LogSort.Default)
375+
case Some(s) => LogSort.parse(s).toRight(GatewayError.BadSort)
376+
}
377+
// Page cursor: opaque string in the wire shape;
378+
// we treat it as a Long offset internally. Empty
379+
// or absent → offset 0. Non-numeric → bad_cursor.
380+
val offsetOrErr: Either[GatewayError, Long] =
381+
raw.pageCursor match {
382+
case None | Some("") => Right(0L)
383+
case Some(s) =>
384+
try {
385+
val n = s.trim.toLong
386+
if (n < 0L) Left(GatewayError.BadCursor) else Right(n)
387+
} catch {
388+
case _: NumberFormatException => Left(GatewayError.BadCursor)
389+
}
390+
}
391+
(sortOrErr, offsetOrErr) match {
392+
case (Left(e), _) => Invalid(e)
393+
case (_, Left(e)) => Invalid(e)
394+
case (Right(sort), Right(offset)) =>
395+
Valid(
396+
ValidatedLogsRequest(
397+
workflowId = normaliseLong(raw.workflowId),
398+
executionId = normaliseLong(raw.executionId),
399+
computingUnitId = normaliseLong(raw.computingUnitId),
400+
userId = normaliseLong(raw.userId),
401+
services = services,
402+
level = level,
403+
query = freeText,
404+
sort = sort,
405+
window = window,
406+
pageSize = pageSize,
407+
offset = offset,
408+
pageCursor = raw.pageCursor
409+
)
410+
)
411+
}
412+
}
413+
}
414+
}
415+
}
416+
}
417+
}

0 commit comments

Comments
 (0)