@@ -21,12 +21,18 @@ package org.apache.texera.web.observability.gateway
2121
2222import com .typesafe .scalalogging .LazyLogging
2323import io .dropwizard .auth .Auth
24+ import javax .annotation .security .RolesAllowed
2425import javax .ws .rs ._
25- import javax .ws .rs .core .{MediaType , Response }
26+ import javax .ws .rs .core .{Context , MediaType , Response }
2627import javax .servlet .http .HttpServletRequest
2728import org .apache .texera .auth .SessionUser
29+ import org .apache .texera .dao .SqlServer
30+ import org .apache .texera .dao .jooq .generated .tables .daos .UserDao
2831import 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 {
99105class 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