otel metrics fix #2664

Merged
mfreeman451 merged 2 commits from refs/pull/2664/head into staging 2026-01-14 05:34:33 +00:00
mfreeman451 commented 2026-01-14 05:14:06 +00:00 (Migrated from github.com)
Owner

Imported from GitHub pull request.

Original GitHub pull request: #2284
Original author: @mfreeman451
Original URL: https://github.com/carverauto/serviceradar/pull/2284
Original created: 2026-01-14T05:14:06Z
Original updated: 2026-01-14T05:34:35Z
Original head: carverauto/serviceradar:chore/fix_otel_metrics_missing
Original base: staging
Original merged: 2026-01-14T05:34:33Z by @mfreeman451

User description

IMPORTANT: Please sign the Developer Certificate of Origin

Thank you for your contribution to ServiceRadar. Please note, when contributing, the developer must include
a DCO sign-off statement indicating the DCO acceptance in one commit message. Here
is an example DCO Signed-off-by line in a commit message:

Signed-off-by: J. Doe <j.doe@domain.com>

Describe your changes

Code checklist before requesting a review

  • I have signed the DCO?
  • The build completes without errors?
  • All tests are passing when running make test?

PR Type

Bug fix


Description

  • Add tenant schema support to OTEL metrics queries

  • Pass scope parameter to metrics query functions

  • Implement schema_for_scope helper for tenant resolution

  • Handle both tenant-aware and default schema queries


Diagram Walkthrough

flowchart LR
  A["Metrics Query Functions"] -->|"receive scope parameter"| B["schema_for_scope"]
  B -->|"extract tenant info"| C["TenantSchemas.schema_for_tenant"]
  C -->|"returns schema or nil"| D["Conditional Query Builder"]
  D -->|"with schema"| E["Query with prefix"]
  D -->|"without schema"| F["Query without prefix"]
  E -->|"execute"| G["Repo.one/all"]
  F -->|"execute"| G

File Walkthrough

Relevant files
Bug fix
index.ex
Add tenant schema support to analytics metrics                     

web-ng/lib/serviceradar_web_ng_web/live/analytics_live/index.ex

  • Add TenantSchemas alias for tenant schema resolution
  • Modify get_hourly_metrics_stats/1 to accept scope parameter
  • Implement conditional query building with schema prefix support
  • Add schema_for_scope/1 and safe_schema_for_tenant/1 helper functions
+68/-23 
index.ex
Add tenant schema support to log metrics queries                 

web-ng/lib/serviceradar_web_ng_web/live/log_live/index.ex

  • Add TenantSchemas alias for tenant schema resolution
  • Update load_duration_stats_from_cagg/1 to accept and use scope
    parameter
  • Update load_sparklines/2 to accept scope and pass to fetch function
  • Modify fetch_sparklines/2 to accept scope and build conditional
    queries with schema prefix
  • Add schema_for_scope/1 and safe_schema_for_tenant/1 helper functions
+86/-32 

Imported from GitHub pull request. Original GitHub pull request: #2284 Original author: @mfreeman451 Original URL: https://github.com/carverauto/serviceradar/pull/2284 Original created: 2026-01-14T05:14:06Z Original updated: 2026-01-14T05:34:35Z Original head: carverauto/serviceradar:chore/fix_otel_metrics_missing Original base: staging Original merged: 2026-01-14T05:34:33Z by @mfreeman451 --- ### **User description** ## IMPORTANT: Please sign the Developer Certificate of Origin Thank you for your contribution to ServiceRadar. Please note, when contributing, the developer must include a [DCO sign-off statement]( https://developercertificate.org/) indicating the DCO acceptance in one commit message. Here is an example DCO Signed-off-by line in a commit message: ``` Signed-off-by: J. Doe <j.doe@domain.com> ``` ## Describe your changes ## Issue ticket number and link ## Code checklist before requesting a review - [ ] I have signed the DCO? - [ ] The build completes without errors? - [ ] All tests are passing when running make test? ___ ### **PR Type** Bug fix ___ ### **Description** - Add tenant schema support to OTEL metrics queries - Pass scope parameter to metrics query functions - Implement schema_for_scope helper for tenant resolution - Handle both tenant-aware and default schema queries ___ ### Diagram Walkthrough ```mermaid flowchart LR A["Metrics Query Functions"] -->|"receive scope parameter"| B["schema_for_scope"] B -->|"extract tenant info"| C["TenantSchemas.schema_for_tenant"] C -->|"returns schema or nil"| D["Conditional Query Builder"] D -->|"with schema"| E["Query with prefix"] D -->|"without schema"| F["Query without prefix"] E -->|"execute"| G["Repo.one/all"] F -->|"execute"| G ``` <details><summary><h3>File Walkthrough</h3></summary> <table><thead><tr><th></th><th align="left">Relevant files</th></tr></thead><tbody><tr><td><strong>Bug fix</strong></td><td><table> <tr> <td> <details> <summary><strong>index.ex</strong><dd><code>Add tenant schema support to analytics metrics</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary> <hr> web-ng/lib/serviceradar_web_ng_web/live/analytics_live/index.ex <ul><li>Add <code>TenantSchemas</code> alias for tenant schema resolution<br> <li> Modify <code>get_hourly_metrics_stats/1</code> to accept scope parameter<br> <li> Implement conditional query building with schema prefix support<br> <li> Add <code>schema_for_scope/1</code> and <code>safe_schema_for_tenant/1</code> helper functions</ul> </details> </td> <td><a href="https://github.com/carverauto/serviceradar/pull/2284/files#diff-35fb1715e171ac9f3240e9c02d2170b636acbb369952d58d352e1f75c91f82e3">+68/-23</a>&nbsp; </td> </tr> <tr> <td> <details> <summary><strong>index.ex</strong><dd><code>Add tenant schema support to log metrics queries</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary> <hr> web-ng/lib/serviceradar_web_ng_web/live/log_live/index.ex <ul><li>Add <code>TenantSchemas</code> alias for tenant schema resolution<br> <li> Update <code>load_duration_stats_from_cagg/1</code> to accept and use scope <br>parameter<br> <li> Update <code>load_sparklines/2</code> to accept scope and pass to fetch function<br> <li> Modify <code>fetch_sparklines/2</code> to accept scope and build conditional <br>queries with schema prefix<br> <li> Add <code>schema_for_scope/1</code> and <code>safe_schema_for_tenant/1</code> helper functions</ul> </details> </td> <td><a href="https://github.com/carverauto/serviceradar/pull/2284/files#diff-5c469994d989d04955c9b8a31536ed518a182b3f46819ecee9c3c22f651a4321">+86/-32</a>&nbsp; </td> </tr> </table></td></tr></tbody></table> </details> ___
qodo-code-review[bot] commented 2026-01-14 05:14:46 +00:00 (Migrated from github.com)
Author
Owner

Imported GitHub PR comment.

Original author: @qodo-code-review[bot]
Original URL: https://github.com/carverauto/serviceradar/pull/2284#issuecomment-3747796015
Original created: 2026-01-14T05:14:46Z

PR Compliance Guide 🔍

Below is a summary of compliance checks for this PR:

Security Compliance
Cross-tenant data access

Description: Tenant-derived schema selection is driven by scope (e.g., %{tenant_id: tenant_id} /
%{active_tenant: active_tenant}) and then used as an Ecto query prefix (prefix: ^schema),
which could enable cross-tenant data access if scope can be influenced by an untrusted
client or if TenantSchemas.schema_for_tenant/1 does not strictly validate/authorize the
tenant-to-schema mapping (the same pattern also exists in
web-ng/lib/serviceradar_web_ng_web/live/log_live/index.ex where schema is applied to both
otel_metrics_hourly_stats and otel_metrics queries).
index.ex [109-207]

Referred Code
defp get_hourly_metrics_stats(scope) do
  cutoff = DateTime.add(DateTime.utc_now(), -24, :hour)
  schema = schema_for_scope(scope)

  query =
    if schema do
      from(s in "otel_metrics_hourly_stats",
        prefix: ^schema,
        where: s.bucket >= ^cutoff,
        select: %{
          total_count: sum(s.total_count),
          error_count: sum(s.error_count),
          slow_count: sum(s.slow_count),
          http_4xx_count: sum(s.http_4xx_count),
          http_5xx_count: sum(s.http_5xx_count),
          grpc_error_count: sum(s.grpc_error_count),
          avg_duration_ms:
            fragment(
              "CASE WHEN SUM(?) > 0 THEN SUM(? * ?) / SUM(?) ELSE 0 END",
              s.total_count,
              s.avg_duration_ms,


 ... (clipped 78 lines)
Ticket Compliance
🎫 No ticket provided
  • Create ticket/issue
Codebase Duplication Compliance
Codebase context is not defined

Follow the guide to enable codebase context checks.

Custom Compliance
🟢
Generic: Comprehensive Audit Trails

Objective: To create a detailed and reliable record of critical system actions for security analysis
and compliance.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Meaningful Naming and Self-Documenting Code

Objective: Ensure all identifiers clearly express their purpose and intent, making code
self-documenting

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Error Handling

Objective: To prevent the leakage of sensitive system information through error messages while
providing sufficient detail for internal debugging.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Robust Error Handling and Edge Case Management

Objective: Ensure comprehensive error handling that provides meaningful context and graceful
degradation

Status:
Narrow rescue scope: safe_schema_for_tenant/1 rescues only ArgumentError, so other exceptions from
TenantSchemas.schema_for_tenant/1 could still crash the LiveView path instead of degrading
gracefully.

Referred Code
defp safe_schema_for_tenant(tenant) do
  TenantSchemas.schema_for_tenant(tenant)
rescue
  ArgumentError -> nil
end

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Logging Practices

Objective: To ensure logs are useful for debugging and auditing without exposing sensitive
information like PII, PHI, or cardholder data.

Status:
Exception details logged: Warning logs interpolate inspect(e) which may include database/query details or tenant
identifiers depending on the exception, requiring verification that no sensitive data can
be emitted to logs.

Referred Code
  e ->
    require Logger
    Logger.warning("Failed to load duration stats from cagg: #{inspect(e)}")
    %{avg_duration_ms: 0.0, p95_duration_ms: 0.0, sample_size: 0}
end

# Load sparkline data for gauge/counter metrics
# Returns a map of metric_name -> list of {bucket, avg_value} tuples
defp load_sparklines(metrics, scope) when is_list(metrics) do
  metric_names = sparkline_metric_names(metrics)

  if metric_names == [] do
    %{}
  else
    fetch_sparklines(metric_names, scope)
  end
rescue
  e ->
    # Log error but don't crash - sparklines are nice-to-have
    require Logger
    Logger.warning("Failed to load sparklines: #{inspect(e)}")


 ... (clipped 1 lines)

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Security-First Input Validation and Data Handling

Objective: Ensure all data inputs are validated, sanitized, and handled securely to prevent
vulnerabilities

Status:
Dynamic schema selection: The DB prefix is derived from scope tenant values via TenantSchemas.schema_for_tenant/1,
so verification is needed that TenantSchemas strictly validates/sanitizes tenant input and
cannot be influenced to access unauthorized schemas.

Referred Code
defp schema_for_scope(nil), do: nil

defp schema_for_scope(%{active_tenant: active_tenant}) when not is_nil(active_tenant) do
  safe_schema_for_tenant(active_tenant)
end

defp schema_for_scope(%{tenant_id: tenant_id}) when is_binary(tenant_id) do
  safe_schema_for_tenant(tenant_id)
end

defp schema_for_scope(_), do: nil

defp safe_schema_for_tenant(tenant) do
  TenantSchemas.schema_for_tenant(tenant)
rescue
  ArgumentError -> nil
end

Learn more about managing compliance generic rules or creating your own custom rules

  • Update
Compliance status legend 🟢 - Fully Compliant
🟡 - Partial Compliant
🔴 - Not Compliant
- Requires Further Human Verification
🏷️ - Compliance label
Imported GitHub PR comment. Original author: @qodo-code-review[bot] Original URL: https://github.com/carverauto/serviceradar/pull/2284#issuecomment-3747796015 Original created: 2026-01-14T05:14:46Z --- ## PR Compliance Guide 🔍 <!-- https://github.com/carverauto/serviceradar/commit/c2a70b7c5787d454802a273b700b496f2ae69208 --> Below is a summary of compliance checks for this PR:<br> <table><tbody><tr><td colspan='2'><strong>Security Compliance</strong></td></tr> <tr><td rowspan=1>⚪</td> <td><details><summary><strong>Cross-tenant data access </strong></summary><br> <b>Description:</b> Tenant-derived schema selection is driven by <code>scope</code> (e.g., <code>%{tenant_id: tenant_id}</code> / <br><code>%{active_tenant: active_tenant}</code>) and then used as an Ecto query <code>prefix</code> (<code>prefix: ^schema</code>), <br>which could enable cross-tenant data access if <code>scope</code> can be influenced by an untrusted <br>client or if <code>TenantSchemas.schema_for_tenant/1</code> does not strictly validate/authorize the <br>tenant-to-schema mapping (the same pattern also exists in <br><code>web-ng/lib/serviceradar_web_ng_web/live/log_live/index.ex</code> where schema is applied to both <br><code>otel_metrics_hourly_stats</code> and <code>otel_metrics</code> queries).<br> <strong><a href='https://github.com/carverauto/serviceradar/pull/2284/files#diff-35fb1715e171ac9f3240e9c02d2170b636acbb369952d58d352e1f75c91f82e3R109-R207'>index.ex [109-207]</a></strong><br> <details open><summary>Referred Code</summary> ```elixir defp get_hourly_metrics_stats(scope) do cutoff = DateTime.add(DateTime.utc_now(), -24, :hour) schema = schema_for_scope(scope) query = if schema do from(s in "otel_metrics_hourly_stats", prefix: ^schema, where: s.bucket >= ^cutoff, select: %{ total_count: sum(s.total_count), error_count: sum(s.error_count), slow_count: sum(s.slow_count), http_4xx_count: sum(s.http_4xx_count), http_5xx_count: sum(s.http_5xx_count), grpc_error_count: sum(s.grpc_error_count), avg_duration_ms: fragment( "CASE WHEN SUM(?) > 0 THEN SUM(? * ?) / SUM(?) ELSE 0 END", s.total_count, s.avg_duration_ms, ... (clipped 78 lines) ``` </details></details></td></tr> <tr><td colspan='2'><strong>Ticket Compliance</strong></td></tr> <tr><td>⚪</td><td><details><summary>🎫 <strong>No ticket provided </strong></summary> - [ ] Create ticket/issue <!-- /create_ticket --create_ticket=true --> </details></td></tr> <tr><td colspan='2'><strong>Codebase Duplication Compliance</strong></td></tr> <tr><td>⚪</td><td><details><summary><strong>Codebase context is not defined </strong></summary> Follow the <a href='https://qodo-merge-docs.qodo.ai/core-abilities/rag_context_enrichment/'>guide</a> to enable codebase context checks. </details></td></tr> <tr><td colspan='2'><strong>Custom Compliance</strong></td></tr> <tr><td rowspan=3>🟢</td><td> <details><summary><strong>Generic: Comprehensive Audit Trails</strong></summary><br> **Objective:** To create a detailed and reliable record of critical system actions for security analysis <br>and compliance.<br> **Status:** Passed<br> > Learn more about managing compliance <a href='https://qodo-merge-docs.qodo.ai/tools/compliance/#configuration-options'>generic rules</a> or creating your own <a href='https://qodo-merge-docs.qodo.ai/tools/compliance/#custom-compliance'>custom rules</a> </details></td></tr> <tr><td> <details><summary><strong>Generic: Meaningful Naming and Self-Documenting Code</strong></summary><br> **Objective:** Ensure all identifiers clearly express their purpose and intent, making code <br>self-documenting<br> **Status:** Passed<br> > Learn more about managing compliance <a href='https://qodo-merge-docs.qodo.ai/tools/compliance/#configuration-options'>generic rules</a> or creating your own <a href='https://qodo-merge-docs.qodo.ai/tools/compliance/#custom-compliance'>custom rules</a> </details></td></tr> <tr><td> <details><summary><strong>Generic: Secure Error Handling</strong></summary><br> **Objective:** To prevent the leakage of sensitive system information through error messages while <br>providing sufficient detail for internal debugging.<br> **Status:** Passed<br> > Learn more about managing compliance <a href='https://qodo-merge-docs.qodo.ai/tools/compliance/#configuration-options'>generic rules</a> or creating your own <a href='https://qodo-merge-docs.qodo.ai/tools/compliance/#custom-compliance'>custom rules</a> </details></td></tr> <tr><td rowspan=3>⚪</td> <td><details> <summary><strong>Generic: Robust Error Handling and Edge Case Management</strong></summary><br> **Objective:** Ensure comprehensive error handling that provides meaningful context and graceful <br>degradation<br> **Status:** <br><a href='https://github.com/carverauto/serviceradar/pull/2284/files#diff-35fb1715e171ac9f3240e9c02d2170b636acbb369952d58d352e1f75c91f82e3R203-R207'><strong>Narrow rescue scope</strong></a>: <code>safe_schema_for_tenant/1</code> rescues only <code>ArgumentError</code>, so other exceptions from <br><code>TenantSchemas.schema_for_tenant/1</code> could still crash the LiveView path instead of degrading <br>gracefully.<br> <details open><summary>Referred Code</summary> ```elixir defp safe_schema_for_tenant(tenant) do TenantSchemas.schema_for_tenant(tenant) rescue ArgumentError -> nil end ``` </details> > Learn more about managing compliance <a href='https://qodo-merge-docs.qodo.ai/tools/compliance/#configuration-options'>generic rules</a> or creating your own <a href='https://qodo-merge-docs.qodo.ai/tools/compliance/#custom-compliance'>custom rules</a> </details></td></tr> <tr><td><details> <summary><strong>Generic: Secure Logging Practices</strong></summary><br> **Objective:** To ensure logs are useful for debugging and auditing without exposing sensitive <br>information like PII, PHI, or cardholder data.<br> **Status:** <br><a href='https://github.com/carverauto/serviceradar/pull/2284/files#diff-5c469994d989d04955c9b8a31536ed518a182b3f46819ecee9c3c22f651a4321R1701-R1722'><strong>Exception details logged</strong></a>: Warning logs interpolate <code>inspect(e)</code> which may include database/query details or tenant <br>identifiers depending on the exception, requiring verification that no sensitive data can <br>be emitted to logs.<br> <details open><summary>Referred Code</summary> ```elixir e -> require Logger Logger.warning("Failed to load duration stats from cagg: #{inspect(e)}") %{avg_duration_ms: 0.0, p95_duration_ms: 0.0, sample_size: 0} end # Load sparkline data for gauge/counter metrics # Returns a map of metric_name -> list of {bucket, avg_value} tuples defp load_sparklines(metrics, scope) when is_list(metrics) do metric_names = sparkline_metric_names(metrics) if metric_names == [] do %{} else fetch_sparklines(metric_names, scope) end rescue e -> # Log error but don't crash - sparklines are nice-to-have require Logger Logger.warning("Failed to load sparklines: #{inspect(e)}") ... (clipped 1 lines) ``` </details> > Learn more about managing compliance <a href='https://qodo-merge-docs.qodo.ai/tools/compliance/#configuration-options'>generic rules</a> or creating your own <a href='https://qodo-merge-docs.qodo.ai/tools/compliance/#custom-compliance'>custom rules</a> </details></td></tr> <tr><td><details> <summary><strong>Generic: Security-First Input Validation and Data Handling</strong></summary><br> **Objective:** Ensure all data inputs are validated, sanitized, and handled securely to prevent <br>vulnerabilities<br> **Status:** <br><a href='https://github.com/carverauto/serviceradar/pull/2284/files#diff-5c469994d989d04955c9b8a31536ed518a182b3f46819ecee9c3c22f651a4321R1727-R1743'><strong>Dynamic schema selection</strong></a>: The DB <code>prefix</code> is derived from <code>scope</code> tenant values via <code>TenantSchemas.schema_for_tenant/1</code>, <br>so verification is needed that <code>TenantSchemas</code> strictly validates/sanitizes tenant input and <br>cannot be influenced to access unauthorized schemas.<br> <details open><summary>Referred Code</summary> ```elixir defp schema_for_scope(nil), do: nil defp schema_for_scope(%{active_tenant: active_tenant}) when not is_nil(active_tenant) do safe_schema_for_tenant(active_tenant) end defp schema_for_scope(%{tenant_id: tenant_id}) when is_binary(tenant_id) do safe_schema_for_tenant(tenant_id) end defp schema_for_scope(_), do: nil defp safe_schema_for_tenant(tenant) do TenantSchemas.schema_for_tenant(tenant) rescue ArgumentError -> nil end ``` </details> > Learn more about managing compliance <a href='https://qodo-merge-docs.qodo.ai/tools/compliance/#configuration-options'>generic rules</a> or creating your own <a href='https://qodo-merge-docs.qodo.ai/tools/compliance/#custom-compliance'>custom rules</a> </details></td></tr> <tr><td align="center" colspan="2"> - [ ] Update <!-- /compliance --update_compliance=true --> </td></tr></tbody></table> <details><summary>Compliance status legend</summary> 🟢 - Fully Compliant<br> 🟡 - Partial Compliant<br> 🔴 - Not Compliant<br> ⚪ - Requires Further Human Verification<br> 🏷️ - Compliance label<br> </details>
qodo-code-review[bot] commented 2026-01-14 05:15:52 +00:00 (Migrated from github.com)
Author
Owner

Imported GitHub PR comment.

Original author: @qodo-code-review[bot]
Original URL: https://github.com/carverauto/serviceradar/pull/2284#issuecomment-3747800053
Original created: 2026-01-14T05:15:52Z

PR Code Suggestions

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
High-level
Refactor to eliminate widespread code duplication
Suggestion Impact:The commit reduces some query duplication by removing the duplicated "no schema prefix" query branches and instead short-circuiting when schema is nil (logging a warning and returning a fallback value). However, it does not implement the core refactor: helpers were not centralized and composable prefixing was not introduced; in fact, a new helper (log_schema_warning_once/2) was duplicated across modules.

code diff:

-    query =
-      if schema do
+    if is_nil(schema) do
+      log_schema_warning_once(
+        :analytics_hourly_stats,
+        "Hourly metrics stats skipped: tenant schema unavailable"
+      )
+      nil
+    else
+      query =
         from(s in "otel_metrics_hourly_stats",
           prefix: ^schema,
           where: s.bucket >= ^cutoff,
@@ -134,47 +140,25 @@
             max_duration_ms: max(s.max_duration_ms)
           }
         )
-      else
-        from(s in "otel_metrics_hourly_stats",
-          where: s.bucket >= ^cutoff,
-          select: %{
-            total_count: sum(s.total_count),
-            error_count: sum(s.error_count),
-            slow_count: sum(s.slow_count),
-            http_4xx_count: sum(s.http_4xx_count),
-            http_5xx_count: sum(s.http_5xx_count),
-            grpc_error_count: sum(s.grpc_error_count),
-            avg_duration_ms:
-              fragment(
-                "CASE WHEN SUM(?) > 0 THEN SUM(? * ?) / SUM(?) ELSE 0 END",
-                s.total_count,
-                s.avg_duration_ms,
-                s.total_count,
-                s.total_count
-              ),
-            p95_duration_ms: max(s.p95_duration_ms),
-            max_duration_ms: max(s.max_duration_ms)
+
+      case Repo.one(query) do
+        %{total_count: total} = stats when not is_nil(total) ->
+          %{
+            total: to_int(total),
+            error: to_int(stats.error_count),
+            slow: to_int(stats.slow_count),
+            http_4xx: to_int(stats.http_4xx_count),
+            http_5xx: to_int(stats.http_5xx_count),
+            grpc_error: to_int(stats.grpc_error_count),
+            avg_duration_ms: to_float(stats.avg_duration_ms),
+            p95_duration_ms: to_float(stats.p95_duration_ms),
+            max_duration_ms: to_float(stats.max_duration_ms)
           }
-        )
+
+        _ ->
+          Logger.debug("Hourly metrics stats not available, falling back to SRQL queries")
+          nil
       end
-
-    case Repo.one(query) do
-      %{total_count: total} = stats when not is_nil(total) ->
-        %{
-          total: to_int(total),
-          error: to_int(stats.error_count),
-          slow: to_int(stats.slow_count),
-          http_4xx: to_int(stats.http_4xx_count),
-          http_5xx: to_int(stats.http_5xx_count),
-          grpc_error: to_int(stats.grpc_error_count),
-          avg_duration_ms: to_float(stats.avg_duration_ms),
-          p95_duration_ms: to_float(stats.p95_duration_ms),
-          max_duration_ms: to_float(stats.max_duration_ms)
-        }
-
-      _ ->
-        Logger.debug("Hourly metrics stats not available, falling back to SRQL queries")
-        nil
     end
   rescue
     error ->
@@ -187,6 +171,18 @@
   defp to_float(v) when is_float(v), do: v
   defp to_float(v) when is_integer(v), do: v * 1.0
   defp to_float(_), do: 0.0
+
+  defp log_schema_warning_once(key, message) do
+    warned_key = {:schema_warning, key}
+
+    if Process.get(warned_key) do
+      :ok
+    else
+      Logger.warning(message)
+      Process.put(warned_key, true)
+      :ok
+    end
+  end
 
   defp schema_for_scope(nil), do: nil

# File: web-ng/lib/serviceradar_web_ng_web/live/log_live/index.ex
@@ -9,6 +9,8 @@
   alias ServiceRadarWebNG.Repo
   alias ServiceRadarWebNGWeb.SRQL.Page, as: SRQLPage
   alias ServiceRadarWebNGWeb.Stats
+
+  require Logger
 
   @default_limit 20
   @max_limit 100
@@ -1650,8 +1652,14 @@
     cutoff = DateTime.add(DateTime.utc_now(), -24, :hour)
     schema = schema_for_scope(scope)
 
-    query =
-      if schema do
+    if is_nil(schema) do
+      log_schema_warning_once(
+        :log_duration_stats,
+        "Duration stats skipped: tenant schema unavailable"
+      )
+      %{avg_duration_ms: 0.0, p95_duration_ms: 0.0, sample_size: 0}
+    else
+      query =
         from(s in "otel_metrics_hourly_stats",
           prefix: ^schema,
           where: s.bucket >= ^cutoff,
@@ -1668,34 +1676,18 @@
             p95_duration_ms: max(s.p95_duration_ms)
           }
         )
-      else
-        from(s in "otel_metrics_hourly_stats",
-          where: s.bucket >= ^cutoff,
-          select: %{
-            total_count: sum(s.total_count),
-            avg_duration_ms:
-              fragment(
-                "CASE WHEN SUM(?) > 0 THEN SUM(? * ?) / SUM(?) ELSE 0 END",
-                s.total_count,
-                s.avg_duration_ms,
-                s.total_count,
-                s.total_count
-              ),
-            p95_duration_ms: max(s.p95_duration_ms)
+
+      case Repo.one(query) do
+        %{total_count: total} = stats when not is_nil(total) and total > 0 ->
+          %{
+            avg_duration_ms: numeric_to_float(stats.avg_duration_ms),
+            p95_duration_ms: numeric_to_float(stats.p95_duration_ms),
+            sample_size: to_int(total)
           }
-        )
+
+        _ ->
+          %{avg_duration_ms: 0.0, p95_duration_ms: 0.0, sample_size: 0}
       end
-
-    case Repo.one(query) do
-      %{total_count: total} = stats when not is_nil(total) and total > 0 ->
-        %{
-          avg_duration_ms: numeric_to_float(stats.avg_duration_ms),
-          p95_duration_ms: numeric_to_float(stats.p95_duration_ms),
-          sample_size: to_int(total)
-        }
-
-      _ ->
-        %{avg_duration_ms: 0.0, p95_duration_ms: 0.0, sample_size: 0}
     end
   rescue
     e ->
@@ -1740,6 +1732,18 @@
     TenantSchemas.schema_for_tenant(tenant)
   rescue
     ArgumentError -> nil
+  end
+
+  defp log_schema_warning_once(key, message) do
+    warned_key = {:schema_warning, key}
+
+    if Process.get(warned_key) do
+      :ok
+    else
+      Logger.warning(message)
+      Process.put(warned_key, true)
+      :ok
+    end
   end
 
   defp sparkline_metric_names(metrics) do
@@ -1757,8 +1761,14 @@
     cutoff = DateTime.add(DateTime.utc_now(), -2, :hour)
     schema = schema_for_scope(scope)
 
-    query =
-      if schema do
+    if is_nil(schema) do
+      log_schema_warning_once(
+        :log_sparklines,
+        "Sparkline stats skipped: tenant schema unavailable"
+      )
+      %{}
+    else
+      query =
         from(m in "otel_metrics",
           prefix: ^schema,
           where: m.metric_name in ^metric_names and m.timestamp >= ^cutoff,
@@ -1770,22 +1780,11 @@
             avg_value: avg(m.value)
           }
         )
-      else
-        from(m in "otel_metrics",
-          where: m.metric_name in ^metric_names and m.timestamp >= ^cutoff,
-          group_by: [m.metric_name, fragment("time_bucket('5 minutes', ?)", m.timestamp)],
-          order_by: [m.metric_name, fragment("time_bucket('5 minutes', ?)", m.timestamp)],
-          select: %{
-            metric_name: m.metric_name,
-            bucket: fragment("time_bucket('5 minutes', ?)", m.timestamp),
-            avg_value: avg(m.value)
-          }
-        )
-      end
-
-    query
-    |> Repo.all()
-    |> Enum.group_by(& &1.metric_name, fn row -> numeric_to_float(row.avg_value) end)
+
+      query
+      |> Repo.all()
+      |> Enum.group_by(& &1.metric_name, fn row -> numeric_to_float(row.avg_value) end)
+    end
   end

The PR duplicates helper functions for schema resolution and entire Ecto queries
across multiple modules. It is recommended to centralize the helper functions
and use Ecto's composable query features to apply the schema prefix
conditionally, thus avoiding code duplication.

Examples:

web-ng/lib/serviceradar_web_ng_web/live/analytics_live/index.ex [113-159]
    query =
      if schema do
        from(s in "otel_metrics_hourly_stats",
          prefix: ^schema,
          where: s.bucket >= ^cutoff,
          select: %{
            total_count: sum(s.total_count),
            error_count: sum(s.error_count),
            slow_count: sum(s.slow_count),
            http_4xx_count: sum(s.http_4xx_count),

 ... (clipped 37 lines)
web-ng/lib/serviceradar_web_ng_web/live/log_live/index.ex [1727-1743]
  defp schema_for_scope(nil), do: nil

  defp schema_for_scope(%{active_tenant: active_tenant}) when not is_nil(active_tenant) do
    safe_schema_for_tenant(active_tenant)
  end

  defp schema_for_scope(%{tenant_id: tenant_id}) when is_binary(tenant_id) do
    safe_schema_for_tenant(tenant_id)
  end


 ... (clipped 7 lines)

Solution Walkthrough:

Before:

# In web-ng/lib/serviceradar_web_ng_web/live/analytics_live/index.ex
defp get_hourly_metrics_stats(scope) do
  schema = schema_for_scope(scope)
  query =
    if schema do
      from(s in "otel_metrics_hourly_stats",
        prefix: ^schema,
        # ... duplicated query logic
      )
    else
      from(s in "otel_metrics_hourly_stats",
        # ... identical duplicated query logic
      )
    end
  Repo.one(query)
end

defp schema_for_scope(...), do: ... # Helper defined here

After:

# In a new shared helper module, e.g., `TenantHelpers.ex`
defmodule TenantHelpers do
  def schema_for_scope(...), do: ...
end

# In web-ng/lib/serviceradar_web_ng_web/live/analytics_live/index.ex
defp get_hourly_metrics_stats(scope) do
  schema = TenantHelpers.schema_for_scope(scope) # Use shared helper
  
  base_query = 
    from(s in "otel_metrics_hourly_stats",
      # ... query logic defined once
    )

  query = if schema, do: Ecto.Query.prefix(base_query, schema), else: base_query
  
  Repo.one(query)
end

Suggestion importance[1-10]: 9

__

Why: The suggestion correctly identifies significant code duplication of both helper functions and entire Ecto queries, which is a major maintainability issue affecting all modified files.

High
Security
Prevent public schema querying
Suggestion Impact:The commit removed the fallback query that ran without a schema prefix (public schema) and now short-circuits when schema is nil, logging a warning once and returning nil instead. This addresses the core security concern (no public schema querying), though it does not return a zeroed stats map as suggested.

code diff:

@@ -110,8 +110,14 @@
     cutoff = DateTime.add(DateTime.utc_now(), -24, :hour)
     schema = schema_for_scope(scope)
 
-    query =
-      if schema do
+    if is_nil(schema) do
+      log_schema_warning_once(
+        :analytics_hourly_stats,
+        "Hourly metrics stats skipped: tenant schema unavailable"
+      )
+      nil
+    else
+      query =
         from(s in "otel_metrics_hourly_stats",
           prefix: ^schema,
           where: s.bucket >= ^cutoff,
@@ -134,47 +140,25 @@
             max_duration_ms: max(s.max_duration_ms)
           }
         )
-      else
-        from(s in "otel_metrics_hourly_stats",
-          where: s.bucket >= ^cutoff,
-          select: %{
-            total_count: sum(s.total_count),
-            error_count: sum(s.error_count),
-            slow_count: sum(s.slow_count),
-            http_4xx_count: sum(s.http_4xx_count),
-            http_5xx_count: sum(s.http_5xx_count),
-            grpc_error_count: sum(s.grpc_error_count),
-            avg_duration_ms:
-              fragment(
-                "CASE WHEN SUM(?) > 0 THEN SUM(? * ?) / SUM(?) ELSE 0 END",
-                s.total_count,
-                s.avg_duration_ms,
-                s.total_count,
-                s.total_count
-              ),
-            p95_duration_ms: max(s.p95_duration_ms),
-            max_duration_ms: max(s.max_duration_ms)
+
+      case Repo.one(query) do
+        %{total_count: total} = stats when not is_nil(total) ->
+          %{
+            total: to_int(total),
+            error: to_int(stats.error_count),
+            slow: to_int(stats.slow_count),
+            http_4xx: to_int(stats.http_4xx_count),
+            http_5xx: to_int(stats.http_5xx_count),
+            grpc_error: to_int(stats.grpc_error_count),
+            avg_duration_ms: to_float(stats.avg_duration_ms),
+            p95_duration_ms: to_float(stats.p95_duration_ms),
+            max_duration_ms: to_float(stats.max_duration_ms)
           }
-        )
+
+        _ ->
+          Logger.debug("Hourly metrics stats not available, falling back to SRQL queries")
+          nil
       end
-
-    case Repo.one(query) do
-      %{total_count: total} = stats when not is_nil(total) ->
-        %{
-          total: to_int(total),
-          error: to_int(stats.error_count),
-          slow: to_int(stats.slow_count),
-          http_4xx: to_int(stats.http_4xx_count),
-          http_5xx: to_int(stats.http_5xx_count),
-          grpc_error: to_int(stats.grpc_error_count),
-          avg_duration_ms: to_float(stats.avg_duration_ms),
-          p95_duration_ms: to_float(stats.p95_duration_ms),
-          max_duration_ms: to_float(stats.max_duration_ms)
-        }
-
-      _ ->
-        Logger.debug("Hourly metrics stats not available, falling back to SRQL queries")
-        nil
     end
   rescue
     error ->
@@ -187,6 +171,18 @@
   defp to_float(v) when is_float(v), do: v
   defp to_float(v) when is_integer(v), do: v * 1.0
   defp to_float(_), do: 0.0
+
+  defp log_schema_warning_once(key, message) do
+    warned_key = {:schema_warning, key}
+
+    if Process.get(warned_key) do
+      :ok
+    else
+      Logger.warning(message)
+      Process.put(warned_key, true)
+      :ok
+    end
+  end
 

To prevent potential data leaks, avoid querying the public schema when a tenant
schema is not found. Instead, return a zeroed stats map directly.

web-ng/lib/serviceradar_web_ng_web/live/analytics_live/index.ex [111-159]

 schema = schema_for_scope(scope)
 
-query =
-  if schema do
+if is_nil(schema) do
+  %{total_count: 0, error_count: 0, slow_count: 0,
+    http_4xx_count: 0, http_5xx_count: 0, grpc_error_count: 0,
+    avg_duration_ms: 0.0, p95_duration_ms: 0.0, max_duration_ms: 0.0}
+else
+  query =
     from(s in "otel_metrics_hourly_stats",
       prefix: ^schema,
       where: s.bucket >= ^cutoff,
       select: %{...}
     )
-  else
-    from(s in "otel_metrics_hourly_stats",
-      where: s.bucket >= ^cutoff,
-      select: %{...}
-    )
+
+  case Repo.one(query) do
+    ...
   end
+end

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 9

__

Why: This suggestion correctly identifies a potential security vulnerability where failing to find a tenant schema results in querying the public schema, which could lead to cross-tenant data leaks.

High
  • Update
Imported GitHub PR comment. Original author: @qodo-code-review[bot] Original URL: https://github.com/carverauto/serviceradar/pull/2284#issuecomment-3747800053 Original created: 2026-01-14T05:15:52Z --- ## PR Code Suggestions ✨ <!-- c2a70b7 --> Explore these optional code suggestions: <table><thead><tr><td><strong>Category</strong></td><td align=left><strong>Suggestion&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </strong></td><td align=center><strong>Impact</strong></td></tr><tbody><tr><td rowspan=1>High-level</td> <td> <details><summary>✅ <s>Refactor to eliminate widespread code duplication</s></summary> ___ <details><summary><b>Suggestion Impact:</b></summary>The commit reduces some query duplication by removing the duplicated "no schema prefix" query branches and instead short-circuiting when schema is nil (logging a warning and returning a fallback value). However, it does not implement the core refactor: helpers were not centralized and composable prefixing was not introduced; in fact, a new helper (log_schema_warning_once/2) was duplicated across modules. code diff: ```diff - query = - if schema do + if is_nil(schema) do + log_schema_warning_once( + :analytics_hourly_stats, + "Hourly metrics stats skipped: tenant schema unavailable" + ) + nil + else + query = from(s in "otel_metrics_hourly_stats", prefix: ^schema, where: s.bucket >= ^cutoff, @@ -134,47 +140,25 @@ max_duration_ms: max(s.max_duration_ms) } ) - else - from(s in "otel_metrics_hourly_stats", - where: s.bucket >= ^cutoff, - select: %{ - total_count: sum(s.total_count), - error_count: sum(s.error_count), - slow_count: sum(s.slow_count), - http_4xx_count: sum(s.http_4xx_count), - http_5xx_count: sum(s.http_5xx_count), - grpc_error_count: sum(s.grpc_error_count), - avg_duration_ms: - fragment( - "CASE WHEN SUM(?) > 0 THEN SUM(? * ?) / SUM(?) ELSE 0 END", - s.total_count, - s.avg_duration_ms, - s.total_count, - s.total_count - ), - p95_duration_ms: max(s.p95_duration_ms), - max_duration_ms: max(s.max_duration_ms) + + case Repo.one(query) do + %{total_count: total} = stats when not is_nil(total) -> + %{ + total: to_int(total), + error: to_int(stats.error_count), + slow: to_int(stats.slow_count), + http_4xx: to_int(stats.http_4xx_count), + http_5xx: to_int(stats.http_5xx_count), + grpc_error: to_int(stats.grpc_error_count), + avg_duration_ms: to_float(stats.avg_duration_ms), + p95_duration_ms: to_float(stats.p95_duration_ms), + max_duration_ms: to_float(stats.max_duration_ms) } - ) + + _ -> + Logger.debug("Hourly metrics stats not available, falling back to SRQL queries") + nil end - - case Repo.one(query) do - %{total_count: total} = stats when not is_nil(total) -> - %{ - total: to_int(total), - error: to_int(stats.error_count), - slow: to_int(stats.slow_count), - http_4xx: to_int(stats.http_4xx_count), - http_5xx: to_int(stats.http_5xx_count), - grpc_error: to_int(stats.grpc_error_count), - avg_duration_ms: to_float(stats.avg_duration_ms), - p95_duration_ms: to_float(stats.p95_duration_ms), - max_duration_ms: to_float(stats.max_duration_ms) - } - - _ -> - Logger.debug("Hourly metrics stats not available, falling back to SRQL queries") - nil end rescue error -> @@ -187,6 +171,18 @@ defp to_float(v) when is_float(v), do: v defp to_float(v) when is_integer(v), do: v * 1.0 defp to_float(_), do: 0.0 + + defp log_schema_warning_once(key, message) do + warned_key = {:schema_warning, key} + + if Process.get(warned_key) do + :ok + else + Logger.warning(message) + Process.put(warned_key, true) + :ok + end + end defp schema_for_scope(nil), do: nil # File: web-ng/lib/serviceradar_web_ng_web/live/log_live/index.ex @@ -9,6 +9,8 @@ alias ServiceRadarWebNG.Repo alias ServiceRadarWebNGWeb.SRQL.Page, as: SRQLPage alias ServiceRadarWebNGWeb.Stats + + require Logger @default_limit 20 @max_limit 100 @@ -1650,8 +1652,14 @@ cutoff = DateTime.add(DateTime.utc_now(), -24, :hour) schema = schema_for_scope(scope) - query = - if schema do + if is_nil(schema) do + log_schema_warning_once( + :log_duration_stats, + "Duration stats skipped: tenant schema unavailable" + ) + %{avg_duration_ms: 0.0, p95_duration_ms: 0.0, sample_size: 0} + else + query = from(s in "otel_metrics_hourly_stats", prefix: ^schema, where: s.bucket >= ^cutoff, @@ -1668,34 +1676,18 @@ p95_duration_ms: max(s.p95_duration_ms) } ) - else - from(s in "otel_metrics_hourly_stats", - where: s.bucket >= ^cutoff, - select: %{ - total_count: sum(s.total_count), - avg_duration_ms: - fragment( - "CASE WHEN SUM(?) > 0 THEN SUM(? * ?) / SUM(?) ELSE 0 END", - s.total_count, - s.avg_duration_ms, - s.total_count, - s.total_count - ), - p95_duration_ms: max(s.p95_duration_ms) + + case Repo.one(query) do + %{total_count: total} = stats when not is_nil(total) and total > 0 -> + %{ + avg_duration_ms: numeric_to_float(stats.avg_duration_ms), + p95_duration_ms: numeric_to_float(stats.p95_duration_ms), + sample_size: to_int(total) } - ) + + _ -> + %{avg_duration_ms: 0.0, p95_duration_ms: 0.0, sample_size: 0} end - - case Repo.one(query) do - %{total_count: total} = stats when not is_nil(total) and total > 0 -> - %{ - avg_duration_ms: numeric_to_float(stats.avg_duration_ms), - p95_duration_ms: numeric_to_float(stats.p95_duration_ms), - sample_size: to_int(total) - } - - _ -> - %{avg_duration_ms: 0.0, p95_duration_ms: 0.0, sample_size: 0} end rescue e -> @@ -1740,6 +1732,18 @@ TenantSchemas.schema_for_tenant(tenant) rescue ArgumentError -> nil + end + + defp log_schema_warning_once(key, message) do + warned_key = {:schema_warning, key} + + if Process.get(warned_key) do + :ok + else + Logger.warning(message) + Process.put(warned_key, true) + :ok + end end defp sparkline_metric_names(metrics) do @@ -1757,8 +1761,14 @@ cutoff = DateTime.add(DateTime.utc_now(), -2, :hour) schema = schema_for_scope(scope) - query = - if schema do + if is_nil(schema) do + log_schema_warning_once( + :log_sparklines, + "Sparkline stats skipped: tenant schema unavailable" + ) + %{} + else + query = from(m in "otel_metrics", prefix: ^schema, where: m.metric_name in ^metric_names and m.timestamp >= ^cutoff, @@ -1770,22 +1780,11 @@ avg_value: avg(m.value) } ) - else - from(m in "otel_metrics", - where: m.metric_name in ^metric_names and m.timestamp >= ^cutoff, - group_by: [m.metric_name, fragment("time_bucket('5 minutes', ?)", m.timestamp)], - order_by: [m.metric_name, fragment("time_bucket('5 minutes', ?)", m.timestamp)], - select: %{ - metric_name: m.metric_name, - bucket: fragment("time_bucket('5 minutes', ?)", m.timestamp), - avg_value: avg(m.value) - } - ) - end - - query - |> Repo.all() - |> Enum.group_by(& &1.metric_name, fn row -> numeric_to_float(row.avg_value) end) + + query + |> Repo.all() + |> Enum.group_by(& &1.metric_name, fn row -> numeric_to_float(row.avg_value) end) + end end ``` </details> ___ **The PR duplicates helper functions for schema resolution and entire Ecto queries <br>across multiple modules. It is recommended to centralize the helper functions <br>and use Ecto's composable query features to apply the schema prefix <br>conditionally, thus avoiding code duplication.** ### Examples: <details> <summary> <a href="https://github.com/carverauto/serviceradar/pull/2284/files#diff-35fb1715e171ac9f3240e9c02d2170b636acbb369952d58d352e1f75c91f82e3R113-R159">web-ng/lib/serviceradar_web_ng_web/live/analytics_live/index.ex [113-159]</a> </summary> ```elixir query = if schema do from(s in "otel_metrics_hourly_stats", prefix: ^schema, where: s.bucket >= ^cutoff, select: %{ total_count: sum(s.total_count), error_count: sum(s.error_count), slow_count: sum(s.slow_count), http_4xx_count: sum(s.http_4xx_count), ... (clipped 37 lines) ``` </details> <details> <summary> <a href="https://github.com/carverauto/serviceradar/pull/2284/files#diff-5c469994d989d04955c9b8a31536ed518a182b3f46819ecee9c3c22f651a4321R1727-R1743">web-ng/lib/serviceradar_web_ng_web/live/log_live/index.ex [1727-1743]</a> </summary> ```elixir defp schema_for_scope(nil), do: nil defp schema_for_scope(%{active_tenant: active_tenant}) when not is_nil(active_tenant) do safe_schema_for_tenant(active_tenant) end defp schema_for_scope(%{tenant_id: tenant_id}) when is_binary(tenant_id) do safe_schema_for_tenant(tenant_id) end ... (clipped 7 lines) ``` </details> ### Solution Walkthrough: #### Before: ```elixir # In web-ng/lib/serviceradar_web_ng_web/live/analytics_live/index.ex defp get_hourly_metrics_stats(scope) do schema = schema_for_scope(scope) query = if schema do from(s in "otel_metrics_hourly_stats", prefix: ^schema, # ... duplicated query logic ) else from(s in "otel_metrics_hourly_stats", # ... identical duplicated query logic ) end Repo.one(query) end defp schema_for_scope(...), do: ... # Helper defined here ``` #### After: ```elixir # In a new shared helper module, e.g., `TenantHelpers.ex` defmodule TenantHelpers do def schema_for_scope(...), do: ... end # In web-ng/lib/serviceradar_web_ng_web/live/analytics_live/index.ex defp get_hourly_metrics_stats(scope) do schema = TenantHelpers.schema_for_scope(scope) # Use shared helper base_query = from(s in "otel_metrics_hourly_stats", # ... query logic defined once ) query = if schema, do: Ecto.Query.prefix(base_query, schema), else: base_query Repo.one(query) end ``` <details><summary>Suggestion importance[1-10]: 9</summary> __ Why: The suggestion correctly identifies significant code duplication of both helper functions and entire Ecto queries, which is a major maintainability issue affecting all modified files. </details></details></td><td align=center>High </td></tr><tr><td rowspan=1>Security</td> <td> <details><summary>✅ <s>Prevent public schema querying</s></summary> ___ <details><summary><b>Suggestion Impact:</b></summary>The commit removed the fallback query that ran without a schema prefix (public schema) and now short-circuits when schema is nil, logging a warning once and returning nil instead. This addresses the core security concern (no public schema querying), though it does not return a zeroed stats map as suggested. code diff: ```diff @@ -110,8 +110,14 @@ cutoff = DateTime.add(DateTime.utc_now(), -24, :hour) schema = schema_for_scope(scope) - query = - if schema do + if is_nil(schema) do + log_schema_warning_once( + :analytics_hourly_stats, + "Hourly metrics stats skipped: tenant schema unavailable" + ) + nil + else + query = from(s in "otel_metrics_hourly_stats", prefix: ^schema, where: s.bucket >= ^cutoff, @@ -134,47 +140,25 @@ max_duration_ms: max(s.max_duration_ms) } ) - else - from(s in "otel_metrics_hourly_stats", - where: s.bucket >= ^cutoff, - select: %{ - total_count: sum(s.total_count), - error_count: sum(s.error_count), - slow_count: sum(s.slow_count), - http_4xx_count: sum(s.http_4xx_count), - http_5xx_count: sum(s.http_5xx_count), - grpc_error_count: sum(s.grpc_error_count), - avg_duration_ms: - fragment( - "CASE WHEN SUM(?) > 0 THEN SUM(? * ?) / SUM(?) ELSE 0 END", - s.total_count, - s.avg_duration_ms, - s.total_count, - s.total_count - ), - p95_duration_ms: max(s.p95_duration_ms), - max_duration_ms: max(s.max_duration_ms) + + case Repo.one(query) do + %{total_count: total} = stats when not is_nil(total) -> + %{ + total: to_int(total), + error: to_int(stats.error_count), + slow: to_int(stats.slow_count), + http_4xx: to_int(stats.http_4xx_count), + http_5xx: to_int(stats.http_5xx_count), + grpc_error: to_int(stats.grpc_error_count), + avg_duration_ms: to_float(stats.avg_duration_ms), + p95_duration_ms: to_float(stats.p95_duration_ms), + max_duration_ms: to_float(stats.max_duration_ms) } - ) + + _ -> + Logger.debug("Hourly metrics stats not available, falling back to SRQL queries") + nil end - - case Repo.one(query) do - %{total_count: total} = stats when not is_nil(total) -> - %{ - total: to_int(total), - error: to_int(stats.error_count), - slow: to_int(stats.slow_count), - http_4xx: to_int(stats.http_4xx_count), - http_5xx: to_int(stats.http_5xx_count), - grpc_error: to_int(stats.grpc_error_count), - avg_duration_ms: to_float(stats.avg_duration_ms), - p95_duration_ms: to_float(stats.p95_duration_ms), - max_duration_ms: to_float(stats.max_duration_ms) - } - - _ -> - Logger.debug("Hourly metrics stats not available, falling back to SRQL queries") - nil end rescue error -> @@ -187,6 +171,18 @@ defp to_float(v) when is_float(v), do: v defp to_float(v) when is_integer(v), do: v * 1.0 defp to_float(_), do: 0.0 + + defp log_schema_warning_once(key, message) do + warned_key = {:schema_warning, key} + + if Process.get(warned_key) do + :ok + else + Logger.warning(message) + Process.put(warned_key, true) + :ok + end + end ``` </details> ___ **To prevent potential data leaks, avoid querying the public schema when a tenant <br>schema is not found. Instead, return a zeroed stats map directly.** [web-ng/lib/serviceradar_web_ng_web/live/analytics_live/index.ex [111-159]](https://github.com/carverauto/serviceradar/pull/2284/files#diff-35fb1715e171ac9f3240e9c02d2170b636acbb369952d58d352e1f75c91f82e3R111-R159) ```diff schema = schema_for_scope(scope) -query = - if schema do +if is_nil(schema) do + %{total_count: 0, error_count: 0, slow_count: 0, + http_4xx_count: 0, http_5xx_count: 0, grpc_error_count: 0, + avg_duration_ms: 0.0, p95_duration_ms: 0.0, max_duration_ms: 0.0} +else + query = from(s in "otel_metrics_hourly_stats", prefix: ^schema, where: s.bucket >= ^cutoff, select: %{...} ) - else - from(s in "otel_metrics_hourly_stats", - where: s.bucket >= ^cutoff, - select: %{...} - ) + + case Repo.one(query) do + ... end +end ``` `[To ensure code accuracy, apply this suggestion manually]` <details><summary>Suggestion importance[1-10]: 9</summary> __ Why: This suggestion correctly identifies a potential security vulnerability where failing to find a tenant schema results in querying the public schema, which could lead to cross-tenant data leaks. </details></details></td><td align=center>High </td></tr> <tr><td align="center" colspan="2"> - [ ] Update <!-- /improve_multi --more_suggestions=true --> </td><td></td></tr></tbody></table>
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
carverauto/serviceradar!2664
No description provided.