This is an automated email from the ASF dual-hosted git repository.

raghavyadav01 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to refs/heads/master by this push:
     new 396e7211280 Add per-key segment build observability for OPEN_STRUCT 
columns (PR 4/4) (#19041)
396e7211280 is described below

commit 396e72112801576412cb73d264b727e66b484ef3
Author: tarun11Mavani <[email protected]>
AuthorDate: Tue Aug 25 02:35:24 2026 +0530

    Add per-key segment build observability for OPEN_STRUCT columns (PR 4/4) 
(#19041)
    
    * feat(open_struct): per-key segment build observability metrics
    
    Add per-key segment build observability for OPEN_STRUCT columns.
    Metric cardinality is bounded to configured dense keys (not data-driven
    key discovery) to prevent unbounded registry growth.
    
    Meters (column-level):
    - OPEN_STRUCT_TYPE_COERCION_FAILURES
    - OPEN_STRUCT_TYPE_INFERENCE_FAILURES
    
    Gauges (column-level):
    - OPEN_STRUCT_DENSE_KEY_COUNT
    - OPEN_STRUCT_SPARSE_KEY_COUNT
    - OPEN_STRUCT_TOTAL_KEYS_DISCOVERED
    
    Gauge (per dense key):
    - OPEN_STRUCT_KEY_FILL_RATE
    
    * fix(open_struct): address review feedback on build observability metrics
    
    Reworks the OPEN_STRUCT segment-build metrics from PR review.
    
    Scrape rules (server.yml):
    Adds three rules ahead of the generic ones. The per-key gauge previously
    matched no rule at all; the column-level gauges and meters did match, but
    the generic "tableNameWithType + partitionId" and "rawTableName" rules
    claimed them first and exported the column as partition="<column>" and
    table="<table>.<column>" respectively. Keys are captured raw rather than
    sanitised: '$', '.' and spaces are all legal in a Prometheus label value,
    and sanitising would collapse user.id, user-id and user_id.
    
    Fill rate:
    Replaces OPEN_STRUCT_KEY_FILL_RATE with OPEN_STRUCT_KEY_DOC_COUNT and
    OPEN_STRUCT_SEGMENT_DOC_COUNT. Integer division truncated a dense key
    present in a handful of docs to 0, indistinguishable from no data and
    exactly the case worth alerting on. No alias is kept: the gauge was never
    released upstream. ServerGauge.DOCUMENT_COUNT is unsuitable as the
    denominator (accumulating server-wide total, keyed on table only, emitted
    at segment load rather than build).
    
    Type-inference fallback:
    Extracts OpenStructTypeInference#resolve, shared by the consuming and the
    sealed build paths so the fallback rule cannot drift. A value that maps to
    no DataType is stored as its serialized string form on both paths; dropping
    on one made a doc resolve differently either side of the seal boundary and
    made the REALTIME and OFFLINE halves of a hybrid table disagree.
    
    Failure counting:
    Counts per value rather than once per key, since the key's inferred type is
    cached on first sighting. A value that is unmappable but lands on a key
    already typed non-STRING is dropped by coercion and counted only there --
    counting it in both meters recorded one dropped value twice and made the
    seal-time log claim it fell back to STRING when it had not. Meters are
    keyed on the column, not the key: they fire on malformed input, so an
    id-like key would mint one meter per id and meters are never removed from
    the registry.
    
    Logging:
    Seal-time summaries log a total plus the top 5 offending keys at INFO and
    the full map at DEBUG; the key space is user-controlled. The consuming-path
    WARN fires only on a key's first sighting.
    
    Tests:
    Covers per-value counting, the unmappable-on-typed-key case, gauge emission
    including a fill rate that would truncate to zero, consuming/sealed parity
    for Map and List values, top-N capping, and the scrape rules -- including a
    first-match-wins test over the whole ordered rule list, since the ordering
    is what makes the new rules correct. PrometheusTemplateRegexpTest selected
    rules by name via findFirst, but rule names are only the exported
    metric-name template and are not unique; it now requires a unique match and
    takes a discriminator.
    
    * test(open_struct): cover the exported shape of the OPEN_STRUCT metrics
    
    PrometheusTemplateRegexpTest only runs Pattern.matches() against strings; it
    never exercises the name:/labels: substitution. ServerPrometheusMetricsTest 
is
    the one that starts a real JmxCollector over server.yml and scrapes, and 
being
    data-provider-driven over ServerGauge.values() it already ran the new 
gauges --
    but through the default branch, which emits a plain tableNameWithType. That
    produces openStructKeyDocCount.<table>_REALTIME with no .column$key 
segment, a
    name the OPEN_STRUCT rule cannot match, so a generic rule claimed it and the
    assertion passed against a shape that never occurs in production. The two
    column-level meters had the same problem: no column segment, so their rule
    never fired either.
    
    Dispatches all seven separately, emitting through the keyed APIs with the 
real
    third name segment built via OpenStructNaming#materializedColumnName, and
    asserts the full label set including column and key.
    
    The test key is "clicks.v2$promo-code". The embedded '$' is the case worth
    pinning, since '$' is the column/key delimiter and only a column group that
    stops at the first one plus a greedy key group can round-trip it. Spaces are
    equally legal in a label value but are not covered: 
PromMetric#fromExportedMetric
    splits the scrape line on the first space, so that defeats the harness 
rather
    than the exporter.
    
    Verified by deleting the OPEN_STRUCT rule block from server.yml, which fails
    exactly these seven and nothing else. Runs under Yammer only -- the 
Dropwizard
    suite is disabled repo-wide.
    
    * fix(open_struct): bound the per-key gauge to configured dense keys
    
    Addresses the two open points from review.
    
    Per-key gauge cardinality:
    OPEN_STRUCT_KEY_DOC_COUNT now iterates the configured denseKeys rather
    than _resolvedDenseKeys. The earlier "config-bounded" claim was wrong:
    DEFAULT_MAX_DENSE_KEYS is -1 and denseKeys defaults to empty, so under the
    default config _resolvedDenseKeys is every key at or above the 0.5 fill
    threshold -- data-driven, and gauges are never removed from the registry.
    maxDenseKeys > 0 is not a sufficient gate either, since it caps each
    segment's set but different segments resolve different keys, leaving the
    union across segments unbounded. An explicit denseKeys list is the only
    operator-owned bound.
    
    A configured key absent from the segment is skipped rather than reported
    as 0. One that appeared but lost its slot to the maxDenseKeys cap still
    reports: a configured key not earning its materialized column is the case
    worth seeing. materializedColumnName stays the source of the metric key in
    that case -- it is only the "<column>$<key>" identifier the scrape rule
    splits back into column and key labels, not a claim of an on-disk column,
    and a second helper with the same format would drift from parseParentColumn
    and the server.yml rule.
    
    Gauge naming:
    Renames OPEN_STRUCT_TOTAL_KEYS_DISCOVERED to OPEN_STRUCT_SEGMENT_KEY_COUNT.
    "Total ... discovered" read as a table-wide cumulative count, but all five
    gauges are keyed on (table, column) with no segment dimension, so each seal
    overwrites the last and they mean "the most recently sealed segment". The
    new name matches the OPEN_STRUCT_SEGMENT_DOC_COUNT convention already
    established for that scope. No alias: the gauge never reached master.
    
    Tests:
    Four cases over the new gating -- nothing emitted under default config,
    only configured keys emitted when others also resolve dense from data, a
    configured key cut by the maxDenseKeys cap still emitted, and a configured
    key absent from the segment skipped.
    
    * fix(open_struct): escape JMX-reserved chars in per-key metric names
    
    OPEN_STRUCT keys come from user JSON. ObjectName.quote backslash-escapes
    exactly '"', '\', '*' and '?' on the way to JMX. An escaped '"' stops the
    exported name matching the per-key rule in server.yml at all, so the metric
    silently loses its column/key labels and falls through to a generic rule;
    the other three match but leave a stray backslash in the label value.
    
    Add OpenStructNaming#metricKey, which percent-escapes those four (plus '%'
    itself, so the mapping stays injective) and is used at the per-key gauge
    emission site. Folding them onto '_' would collide: '_' is a legal key
    character, so 'a"b' and 'a_b' would share one gauge and overwrite each
    other on every seal.
    
    Escaping is deliberately narrow -- '.', '-', '_', ',', '=' and spaces are
    safe in a quoted ObjectName and in a Prometheus label value, so 'user.id',
    'user-id' and 'user_id' stay distinct series.
    
    Tests: ServerPrometheusMetricsTest now drives a '"'-bearing key end to end
    through the JMX exporter; OpenStructColumnSplitterTest pins the emission
    site to metricKey. Both fail if the escaping is reverted.
    
    * refactor(open_struct): trim the observability diff
    
    No behaviour change.
    
    - OpenStructNaming: replace the 27-line hand-rolled escape loop with chained
      String.replace, '%' first so the escapes it introduces are not re-escaped.
      Verified byte-identical to the loop over 400k random keys drawn from
      {%, ", \, *, ?, a, ., -, _, $}.
    - OpenStructColumnSplitter: drop topFailures and MAX_LOGGED_FAILURE_KEYS. 
The
      INFO line already carries the total and the key count, and the full 
per-key
      map is logged at DEBUG; the top-5 tier in between was not worth its own
      method, constant and test. Also drop a local alias for _columnName.
    - AbstractMetrics: compress the removeKeyedTableGauges comment.
    
    * docs(open_struct): trim javadoc on the new observability symbols
    
    Doc-only; no code lines changed.
    
    Cuts rationale that belongs in the PR discussion rather than the API
    contract: the paragraph defending percent-escaping over folding to '_',
    the note on revisiting the escape set if metrics stop going through JMX,
    the "plain class rather than a record" aside, and the restated reasons
    keyed gauge names cannot be recovered from the registry.
    
    * fix(open_struct): remove per-key gauges when the table is deleted
    
    OPEN_STRUCT_KEY_DOC_COUNT registers as <gauge>.<table>.<column>$<key>. The
    sweep in TableDeletionMessageHandler composes only the unkeyed
    <gauge>.<table>, so it could never reach these and they outlived the table.
    
    Re-derive the key set from the table config and remove each one explicitly.
    This works because the gauge is emitted only for explicitly configured
    denseKeys rather than for the ingested key space, so the set is recoverable
    from the config. Keys are resolved before deleteTable() runs, since the only
    in-memory copy of the config lives on the table data manager that call is
    about to discard; resolution failures are logged rather than allowed to
    block deletion.
    
    Keys are derived from the current config, so a key dropped from denseKeys
    after a segment already emitted its gauge is not covered -- that gauge is
    orphaned by the config change itself. Closing that gap needs the emitted
    names tracked at emit time, which is left for a follow-up.
    
    * refactor(open_struct): qualify the per-segment gauges with LAST_SEGMENT
    
    These gauges are written with setOrUpdateTableGauge at segment build time,
    so each build overwrites the previous value for the same (table, column).
    They read as "the most recently built segment", not as a table-wide total,
    but the names did not say so -- unlike the LAST_REALTIME_SEGMENT_* gauges
    alongside them. A dashboard summing openStructDenseKeyCount across replicas
    would have gotten a silently meaningless number. Enum names are permanent
    once shipped, so this lands before the metrics do.
    
      openStructDenseKeyCount   -> openStructLastSegmentDenseKeyCount
      openStructSparseKeyCount  -> openStructLastSegmentSparseKeyCount
      openStructSegmentKeyCount -> openStructLastSegmentKeyCount
      openStructSegmentDocCount -> openStructLastSegmentDocCount
      openStructKeyDocCount     -> openStructLastSegmentKeyDocCount
    
    Pure rename plus the matching scrape rules in server.yml; the block comment
    explaining the semantic shrinks now that the names carry it.
    
    * feat(open_struct): emit the per-key doc-count gauge for every ingested key
    
    OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT fired only for keys named in the
    table's denseKeys config. That answers "is a key I already declared
    earning its materialized column", but not "which key should I declare",
    which is the question the fill rate is actually useful for. Emit for
    every key present in the sealed segment instead -- dense or sparse,
    configured or discovered.
    
    The cost is that the registry entry count now follows the ingested key
    space rather than the config, and table deletion can only sweep the keys
    recoverable from denseKeys, so gauges for discovered keys survive until
    the server restarts. Gating this emission behind a config switch is the
    follow-up; tracking emitted key names at emit time is the alternative to
    re-deriving them from config at deletion.
    
    * feat(open_struct): gate per-key doc-count gauge behind 
perKeyDocCountEnabled (default off)
    
    * fix(open_struct): make per-key type resolution sticky once non-STRING
    
    Once a key's inferred type is established as non-STRING, a later value
    that fails OpenStructTypeInference forced valueType back to STRING for
    that single value, desyncing it from _inferredTypes and corrupting
    _values with a mixed-type list for the key instead of being dropped as
    a coercion failure. Skip inference entirely once established is
    non-STRING, matching MutableOpenStructIndex's fast path; the value
    falls through to the existing coercion try/catch instead.
    
    testUnmappableValueOnTypedKeyCountsOnlyAsCoercionFailure already pins
    this behavior and fails with a ClassCastException without this fix.
    
    * refactor(open_struct): drop dead condition in inference-failure counting
    
    The sticky non-STRING branch above already guarantees `established` is
    null or STRING by the time this else is reached, so the re-check was
    always true. No behavior change.
    
    * refactor(open_struct): rename perKeyDocCountEnabled to 
perKeyMetricsEnabled
    
    The flag now gates more than the doc-count gauge, so name it after what
    it controls at the config level rather than the first metric it enabled.
    
    * chore: retrigger CI (infra flakiness on codeload.github.com)
---
 .../jmx_prometheus_javaagent/configs/server.yml    |  34 +++
 .../apache/pinot/common/metrics/ServerGauge.java   |  20 +-
 .../apache/pinot/common/metrics/ServerMeter.java   |   7 +-
 .../prometheus/PrometheusTemplateRegexpTest.java   | 158 ++++++++++-
 .../prometheus/ServerPrometheusMetricsTest.java    |  49 ++++
 .../indexsegment/mutable/MutableSegmentImpl.java   |   3 +-
 .../impl/openstruct/OpenStructColumnSplitter.java  | 113 ++++++--
 .../index/openstruct/MutableOpenStructIndex.java   |  60 +++--
 .../index/openstruct/OpenStructIndexType.java      |   3 +-
 .../openstruct/OpenStructColumnSplitterTest.java   | 294 +++++++++++++++++++--
 .../MutableOpenStructDataSourceTest.java           |  34 +--
 .../openstruct/MutableOpenStructIndexTest.java     |  72 ++++-
 .../OpenStructConsumingSealedParityTest.java       |  78 +++++-
 .../helix/SegmentMessageHandlerFactory.java        |  54 ++++
 .../helix/SegmentMessageHandlerFactoryTest.java    | 188 +++++++++++++
 .../spi/config/table/OpenStructIndexConfig.java    |  24 +-
 .../apache/pinot/spi/data/OpenStructNaming.java    |  20 ++
 .../config/table/OpenStructIndexConfigTest.java    |  22 ++
 .../pinot/spi/data/OpenStructNamingTest.java       |  64 +++++
 19 files changed, 1197 insertions(+), 100 deletions(-)

diff --git 
a/docker/images/pinot/etc/jmx_prometheus_javaagent/configs/server.yml 
b/docker/images/pinot/etc/jmx_prometheus_javaagent/configs/server.yml
index 4d029798bd2..05c829e7642 100644
--- a/docker/images/pinot/etc/jmx_prometheus_javaagent/configs/server.yml
+++ b/docker/images/pinot/etc/jmx_prometheus_javaagent/configs/server.yml
@@ -1,4 +1,38 @@
 rules:
+# OPEN_STRUCT observability. These MUST precede the generic gauge/meter rules 
below: the column segment
+# would otherwise be consumed by the "tableNameWithType + partitionId" rule 
and exported as partition="<column>",
+# and the column-level meters by the "rawTableName" rule as 
table="<table>.<column>".
+# Per-key gauge. The key is a raw user-supplied JSON key, so it is captured 
last with a greedy ([^"]+) — it may
+# contain '$', '.', '-' or spaces, all of which are legal in a Prometheus 
label value. The closing quote is
+# excluded because the trailing "? is optional and a greedy (.+) would 
otherwise swallow it. The column name
+# cannot contain '.' or '$', so ([^.$"]+) splits the two unambiguously. The 
sparse column renders as key="__sparse__".
+- pattern: 
"\"?org\\.apache\\.pinot\\.common\\.metrics\"?<type=\"ServerMetrics\", 
name=\"?pinot\\.server\\.(openStructLastSegmentKeyDocCount)\\.(([^.]+)\\.)?([^.]*)_(OFFLINE|REALTIME)\\.([^.$\"]+)\\$([^\"]+)\"?><>(\\w+)"
+  name: "pinot_server_$1_$8"
+  cache: true
+  labels:
+    database: "$3"
+    table: "$2$4"
+    tableType: "$5"
+    column: "$6"
+    key: "$7"
+# Column-level OPEN_STRUCT gauges.
+- pattern: 
"\"?org\\.apache\\.pinot\\.common\\.metrics\"?<type=\"ServerMetrics\", 
name=\"?pinot\\.server\\.(openStructLastSegmentDenseKeyCount|openStructLastSegmentSparseKeyCount|openStructLastSegmentKeyCount|openStructLastSegmentDocCount)\\.(([^.]+)\\.)?([^.]*)_(OFFLINE|REALTIME)\\.([^.\"]+)\"?><>(\\w+)"
+  name: "pinot_server_$1_$7"
+  cache: true
+  labels:
+    database: "$3"
+    table: "$2$4"
+    tableType: "$5"
+    column: "$6"
+# Column-level OPEN_STRUCT meters. Meter names put the metric name last, 
unlike gauges.
+- pattern: 
"\"?org\\.apache\\.pinot\\.common\\.metrics\"?<type=\"ServerMetrics\", 
name=\"?pinot\\.server\\.(([^.]+)\\.)?([^.]*)_(OFFLINE|REALTIME)\\.([^.]+)\\.(openStructTypeCoercionFailures|openStructTypeInferenceFailures)\"?><>(\\w+)"
+  name: "pinot_server_$6_$7"
+  cache: true
+  labels:
+    database: "$2"
+    table: "$1$3"
+    tableType: "$4"
+    column: "$5"
 # Gauges that accept tableNameWithType
 - pattern: "\"?org\\.apache\\.pinot\\.common\\.metrics\"?<type=\"?\\w+\"?, 
name=\"?pinot\\.(\\w+)\\.(\\w+)\\.((\\w+)\\.)?(\\w+)_(OFFLINE|REALTIME)\\\"?><>(\\w+)"
   name: "pinot_$1_$2_$7"
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/ServerGauge.java 
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/ServerGauge.java
index 75b68684beb..878693969dd 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/ServerGauge.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/ServerGauge.java
@@ -168,7 +168,25 @@ public enum ServerGauge implements AbstractMetrics.Gauge {
   THROTTLE_EXECUTOR_QUEUE_SIZE("count", true,
       "Current number of tasks in the throttle executor queue"),
   // Workload config fetch status: 1 = success, 0 = failure
-  WORKLOAD_CONFIG_FETCH_STATUS("status", true);
+  WORKLOAD_CONFIG_FETCH_STATUS("status", true),
+  // OPEN_STRUCT segment build observability. Each seal on this server 
overwrites the previous value for the
+  // same (table, column), so these are not table-wide totals and must not be 
summed across segments or
+  // replicas; sampling them over time gives the trend.
+  OPEN_STRUCT_LAST_SEGMENT_DENSE_KEY_COUNT("keys", false,
+      "Number of OPEN_STRUCT keys classified as dense in the most recently 
sealed segment"),
+  OPEN_STRUCT_LAST_SEGMENT_SPARSE_KEY_COUNT("keys", false,
+      "Number of OPEN_STRUCT keys classified as sparse in the most recently 
sealed segment"),
+  OPEN_STRUCT_LAST_SEGMENT_KEY_COUNT("keys", false,
+      "Unique keys in the most recently sealed segment for this OPEN_STRUCT 
column (dense + sparse)"),
+  OPEN_STRUCT_LAST_SEGMENT_DOC_COUNT("documents", false,
+      "Total docs in the most recently sealed segment for this OPEN_STRUCT 
column; denominator for "
+          + "openStructLastSegmentKeyDocCount"),
+  /// When `perKeyMetricsEnabled` is false (default), emitted only for keys 
named in `denseKeys`. When
+  /// true, emitted for every key in the sealed segment — registry entries 
follow the ingested key space.
+  /// See `OpenStructColumnSplitter#emitMetrics`.
+  OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT("documents", false,
+      "Docs in which an OPEN_STRUCT key was present in the most recently 
sealed segment; "
+          + "divide by openStructLastSegmentDocCount for the fill rate");
 
   private final String _gaugeName;
   private final String _unit;
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/ServerMeter.java 
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/ServerMeter.java
index 9020607da71..055d2cb2cbe 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/metrics/ServerMeter.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/metrics/ServerMeter.java
@@ -240,7 +240,12 @@ public enum ServerMeter implements AbstractMetrics.Meter {
   DROPPED_RECORD_COUNT("rows", false),
   CORRUPTED_RECORD_COUNT("rows", false),
   OPEN_STRUCT_TYPE_COERCION_FAILURES("values", false,
-      "Number of OPEN_STRUCT values dropped because the value could not be 
coerced to the key's inferred type"),
+      "Number of OPEN_STRUCT values dropped because the value could not be 
coerced to the key's declared or "
+          + "already-established type"),
+  OPEN_STRUCT_TYPE_INFERENCE_FAILURES("values", false,
+      "Number of OPEN_STRUCT values stored as their serialized string form 
because the value's Java type maps to "
+          + "no Pinot DataType. A value that is instead dropped for failing 
coercion against a key's "
+          + "already-established non-STRING type is counted by 
openStructTypeCoercionFailures, not here"),
   // Workload related metrics
   WORKLOAD_BUDGET_EXCEEDED("workloadBudgetExceeded", true, "Number of times 
workload budget exceeded"),
   WORKLOAD_QUERIES("queries", false),
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/common/metrics/prometheus/PrometheusTemplateRegexpTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/common/metrics/prometheus/PrometheusTemplateRegexpTest.java
index 13d69065d5d..19c665f7a05 100644
--- 
a/pinot-common/src/test/java/org/apache/pinot/common/metrics/prometheus/PrometheusTemplateRegexpTest.java
+++ 
b/pinot-common/src/test/java/org/apache/pinot/common/metrics/prometheus/PrometheusTemplateRegexpTest.java
@@ -172,7 +172,7 @@ public class PrometheusTemplateRegexpTest {
   @Test
   public void testServerTableWithTypeAndPartitionGaugePattern()
       throws Exception {
-    String pattern = loadPatternByName("server.yml", "pinot_server_$1_$7");
+    String pattern = loadPatternByName("server.yml", "pinot_server_$1_$7", 
"pinot\\.server\\.(\\w+)\\.");
     Matcher m = Pattern.compile(pattern).matcher(
         "\"org.apache.pinot.common.metrics\"<type=\"ServerMetrics\", "
             + "name=\"pinot.server.queries.myTable_REALTIME.3\"><>Value");
@@ -184,6 +184,134 @@ public class PrometheusTemplateRegexpTest {
     Assert.assertEquals(m.group(7), "Value");
   }
 
+  // ---- OPEN_STRUCT server patterns ----
+
+  /// server.yml: per-key OPEN_STRUCT gauge. The JMX name embeds the raw 
user-supplied JSON key
+  /// after the `$` separator, so the key group must survive characters the 
generic `\w+` rules
+  /// reject: `$`, `.`, `-` and spaces are all legal in a Prometheus label 
value.
+  @Test
+  public void testServerOpenStructPerKeyGaugePattern()
+      throws Exception {
+    Pattern compiled = Pattern.compile(
+        loadPatternByName("server.yml", "pinot_server_$1_$8", 
"openStructLastSegmentKeyDocCount"));
+
+    Matcher plain = 
compiled.matcher("\"org.apache.pinot.common.metrics\"<type=\"ServerMetrics\", "
+        + 
"name=\"pinot.server.openStructLastSegmentKeyDocCount.myTable_REALTIME.metrics$clicks\"><>Value");
+    Assert.assertTrue(plain.matches(), "Pattern should match per-key 
OPEN_STRUCT gauge");
+    Assert.assertEquals(plain.group(1), "openStructLastSegmentKeyDocCount");
+    Assert.assertEquals(plain.group(4), "myTable");
+    Assert.assertEquals(plain.group(5), "REALTIME");
+    Assert.assertEquals(plain.group(6), "metrics");
+    Assert.assertEquals(plain.group(7), "clicks");
+    Assert.assertEquals(plain.group(8), "Value");
+
+    // A key containing '.' would be swallowed by the generic rules; the 
column group is ([^.$]+)
+    // so the split stays unambiguous and the whole remainder lands in the key 
label.
+    Matcher dotted = 
compiled.matcher("\"org.apache.pinot.common.metrics\"<type=\"ServerMetrics\", "
+        + 
"name=\"pinot.server.openStructLastSegmentKeyDocCount.myDb.myTable_OFFLINE.metrics$user.id\"><>Value");
+    Assert.assertTrue(dotted.matches(), "Pattern should match a key containing 
'.'");
+    Assert.assertEquals(dotted.group(3), "myDb");
+    Assert.assertEquals(dotted.group(4), "myTable");
+    Assert.assertEquals(dotted.group(6), "metrics");
+    Assert.assertEquals(dotted.group(7), "user.id");
+
+    // A key containing '$' — greedy (.+) puts the split at the first '$', 
which is the separator
+    // the splitter emitted, so the trailing '$' stays part of the key.
+    Matcher dollar = 
compiled.matcher("\"org.apache.pinot.common.metrics\"<type=\"ServerMetrics\", "
+        + 
"name=\"pinot.server.openStructLastSegmentKeyDocCount.myTable_OFFLINE.metrics$a$b\"><>Value");
+    Assert.assertTrue(dollar.matches(), "Pattern should match a key containing 
'$'");
+    Assert.assertEquals(dollar.group(6), "metrics");
+    Assert.assertEquals(dollar.group(7), "a$b");
+
+    // The sparse catch-all column is named with the reserved __sparse__ 
suffix rather than a key.
+    Matcher sparse = 
compiled.matcher("\"org.apache.pinot.common.metrics\"<type=\"ServerMetrics\", "
+        + 
"name=\"pinot.server.openStructLastSegmentKeyDocCount.myTable_OFFLINE.metrics$__sparse__\"><>Value");
+    Assert.assertTrue(sparse.matches(), "Pattern should match the sparse 
column");
+    Assert.assertEquals(sparse.group(7), "__sparse__");
+  }
+
+  /// server.yml: column-level OPEN_STRUCT gauges. These must be matched here 
rather than by the
+  /// generic "tableNameWithType + partitionId" rule, which would export the 
column as
+  /// partition="<column>". Every branch of the alternation is exercised: a 
typo in one of them is
+  /// still a valid regexp, so it would pass testAllPatternsAreValidRegexp and 
then silently fail to
+  /// scrape in production.
+  @Test
+  public void testServerOpenStructColumnGaugePattern()
+      throws Exception {
+    Pattern compiled =
+        Pattern.compile(loadPatternByName("server.yml", "pinot_server_$1_$7", 
"openStructLastSegmentDenseKeyCount"));
+    for (String metric : List.of("openStructLastSegmentDenseKeyCount", 
"openStructLastSegmentSparseKeyCount",
+        "openStructLastSegmentKeyCount", "openStructLastSegmentDocCount")) {
+      Matcher m = 
compiled.matcher("\"org.apache.pinot.common.metrics\"<type=\"ServerMetrics\", "
+          + "name=\"pinot.server." + metric + 
".myTable_OFFLINE.metrics\"><>Value");
+      Assert.assertTrue(m.matches(), "Pattern should match column-level 
OPEN_STRUCT gauge " + metric);
+      Assert.assertEquals(m.group(1), metric);
+      Assert.assertEquals(m.group(4), "myTable");
+      Assert.assertEquals(m.group(5), "OFFLINE");
+      Assert.assertEquals(m.group(6), "metrics");
+      Assert.assertEquals(m.group(7), "Value");
+    }
+  }
+
+  /// server.yml: column-level OPEN_STRUCT meters. Meter JMX names put the 
metric name last,
+  /// unlike gauges, so these need their own rule ahead of the generic 
rawTableName meter rule
+  /// (which would otherwise export table="<table>.<column>").
+  @Test
+  public void testServerOpenStructColumnMeterPattern()
+      throws Exception {
+    Pattern compiled = Pattern.compile(
+        loadPatternByName("server.yml", "pinot_server_$6_$7", 
"openStructTypeCoercionFailures"));
+    for (String metric : List.of("openStructTypeCoercionFailures", 
"openStructTypeInferenceFailures")) {
+      Matcher m = 
compiled.matcher("\"org.apache.pinot.common.metrics\"<type=\"ServerMetrics\", "
+          + "name=\"pinot.server.myTable_REALTIME.metrics." + metric + 
"\"><>Count");
+      Assert.assertTrue(m.matches(), "Pattern should match column-level 
OPEN_STRUCT meter " + metric);
+      Assert.assertEquals(m.group(3), "myTable");
+      Assert.assertEquals(m.group(4), "REALTIME");
+      Assert.assertEquals(m.group(5), "metrics");
+      Assert.assertEquals(m.group(6), metric);
+      Assert.assertEquals(m.group(7), "Count");
+    }
+  }
+
+  /// jmx_exporter evaluates rules in file order and stops at the first match, 
so the OPEN_STRUCT
+  /// rules are only correct because they precede the generic ones. Asserting 
the pattern in
+  /// isolation does not cover that: the generic "tableNameWithType + 
partitionId" gauge rule and
+  /// the generic rawTableName meter rule both full-match these names too, and 
would export the
+  /// column as partition="metrics" and table="myTable.metrics" respectively. 
This test evaluates
+  /// the whole ordered list so a future reordering of server.yml fails here 
rather than in prod.
+  @Test
+  public void testServerOpenStructRulesPrecedeGenericRules()
+      throws Exception {
+    List<String> ordered = extractPatterns(CONFIG_BASE_PATH + "/server.yml");
+    assertFirstMatchingPatternContains(ordered,
+        "\"org.apache.pinot.common.metrics\"<type=\"ServerMetrics\", "
+            + 
"name=\"pinot.server.openStructLastSegmentKeyDocCount.myTable_OFFLINE.metrics$clicks\"><>Value",
+        "openStructLastSegmentKeyDocCount");
+    assertFirstMatchingPatternContains(ordered,
+        "\"org.apache.pinot.common.metrics\"<type=\"ServerMetrics\", "
+            + 
"name=\"pinot.server.openStructLastSegmentDenseKeyCount.myTable_OFFLINE.metrics\"><>Value",
+        "openStructLastSegmentDenseKeyCount");
+    assertFirstMatchingPatternContains(ordered,
+        "\"org.apache.pinot.common.metrics\"<type=\"ServerMetrics\", "
+            + 
"name=\"pinot.server.myTable_REALTIME.metrics.openStructTypeInferenceFailures\"><>Count",
+        "openStructTypeInferenceFailures");
+  }
+
+  /// Asserts the first rule in file order that matches `jmxName` is one whose 
pattern contains
+  /// `expectedInPattern`, mirroring jmx_exporter's first-match-wins 
evaluation.
+  private void assertFirstMatchingPatternContains(List<String> 
orderedPatterns, String jmxName,
+      String expectedInPattern) {
+    for (String pattern : orderedPatterns) {
+      if (Pattern.compile(pattern).matcher(jmxName).matches()) {
+        Assert.assertTrue(pattern.contains(expectedInPattern),
+            "First rule matching [" + jmxName + "] was [" + pattern + "], 
expected one containing '"
+                + expectedInPattern + "'. OPEN_STRUCT rules must precede the 
generic rules in server.yml.");
+        return;
+      }
+    }
+    Assert.fail("No rule in server.yml matches [" + jmxName + "]");
+  }
+
   // ---- Controller patterns ----
 
   /// controller.yml: minion task-type gauge.
@@ -252,23 +380,39 @@ public class PrometheusTemplateRegexpTest {
     Assert.assertEquals(m.group(3), "Value");
   }
 
-  /// Returns the pattern string for the rule whose `name` field equals 
`ruleName`.
+  /// Returns the pattern string for the rule whose `name` field equals 
`ruleName`. Fails when more
+  /// than one rule shares that name — use 
[#loadPatternByName(String,String,String)] instead.
+  ///
   /// Keying off the rule name survives YAML rule reorderings — inserting or 
moving a rule in
   /// the config file will not silently shift the index and cause this test to 
assert against
   /// the wrong pattern.
-  @SuppressWarnings("unchecked")
   private String loadPatternByName(String configFile, String ruleName)
       throws Exception {
+    return loadPatternByName(configFile, ruleName, "");
+  }
+
+  /// Same as [#loadPatternByName(String,String)], but narrowed to the rule 
whose pattern also
+  /// contains `patternDiscriminator`. Rule names are only the exported 
metric-name template
+  /// (e.g. `pinot_server_$1_$7`), so several rules can legitimately share 
one; the discriminator
+  /// picks the intended rule instead of silently taking whichever comes first 
in the file.
+  @SuppressWarnings("unchecked")
+  private String loadPatternByName(String configFile, String ruleName, String 
patternDiscriminator)
+      throws Exception {
     Yaml yaml = new Yaml();
     try (FileReader reader = new FileReader(CONFIG_BASE_PATH + "/" + 
configFile)) {
       Map<String, Object> config = yaml.load(reader);
       List<Map<String, Object>> rules = (List<Map<String, Object>>) 
config.get("rules");
-      return rules.stream()
+      List<String> matches = rules.stream()
           .filter(rule -> ruleName.equals(rule.get("name")))
           .map(rule -> (String) rule.get("pattern"))
-          .findFirst()
-          .orElseThrow(() -> new IllegalArgumentException(
-              "No rule with name '" + ruleName + "' found in " + configFile));
+          .filter(pattern -> pattern != null && 
pattern.contains(patternDiscriminator))
+          .collect(Collectors.toList());
+      Assert.assertFalse(matches.isEmpty(),
+          "No rule named '" + ruleName + "' containing '" + 
patternDiscriminator + "' in " + configFile);
+      Assert.assertEquals(matches.size(), 1,
+          "Ambiguous rule name '" + ruleName + "' in " + configFile
+              + "; pass a patternDiscriminator to select one of: " + matches);
+      return matches.get(0);
     }
   }
 
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/common/metrics/prometheus/ServerPrometheusMetricsTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/common/metrics/prometheus/ServerPrometheusMetricsTest.java
index 274a415c55a..fc556d84f20 100644
--- 
a/pinot-common/src/test/java/org/apache/pinot/common/metrics/prometheus/ServerPrometheusMetricsTest.java
+++ 
b/pinot-common/src/test/java/org/apache/pinot/common/metrics/prometheus/ServerPrometheusMetricsTest.java
@@ -28,6 +28,7 @@ import org.apache.pinot.common.metrics.ServerGauge;
 import org.apache.pinot.common.metrics.ServerMeter;
 import org.apache.pinot.common.metrics.ServerMetrics;
 import org.apache.pinot.common.metrics.ServerTimer;
+import org.apache.pinot.spi.data.OpenStructNaming;
 import org.testng.annotations.BeforeClass;
 import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
@@ -66,6 +67,43 @@ public abstract class ServerPrometheusMetricsTest extends 
PinotPrometheusMetrics
       List.of(ServerGauge.REALTIME_OFFHEAP_MEMORY_USED, 
ServerGauge.REALTIME_SEGMENT_NUM_PARTITIONS,
           ServerGauge.LUCENE_INDEXING_DELAY_MS, 
ServerGauge.LUCENE_INDEXING_DELAY_DOCS);
 
+  // OPEN_STRUCT metrics carry a third name segment that the generic rules 
cannot parse: "<column>" for the
+  // column-level ones and "<column>$<key>" for the per-key gauge. Emitting 
them with a plain
+  // tableNameWithType (what the default branches below do) produces a name 
the OPEN_STRUCT rules do not
+  // match at all, so a generic rule claims it and the assertion passes 
against a shape that never occurs in
+  // production. They are dispatched separately so the exported labels are 
checked against the real name.
+  private static final String OPEN_STRUCT_COLUMN = "metrics";
+  // The key as ingested. Exercises an embedded '$', a '.', a '-' and a '"'. 
The '$' is worth pinning
+  // because it is the column/key delimiter, so only a column group that stops 
at the first '$' and a
+  // greedy key group that takes the rest can round-trip it. The '"' is worth 
pinning because
+  // ObjectName.quote backslash-escapes it on the way to JMX, which stops the 
name matching the scrape
+  // rule at all — the metric would silently lose its column/key labels. 
OpenStructNaming#metricKey
+  // percent-escapes it before emission, so the exported label is 
OPEN_STRUCT_KEY_EXPORTED below. Spaces
+  // are legal in a label value but are not covered: 
PromMetric#fromExportedMetric splits the scrape line
+  // on the first space, so a space defeats the harness, not the exporter.
+  private static final String OPEN_STRUCT_KEY = "clicks.v2$promo\"code";
+  // '"' escaped to '%22'; '.', '-' and '$' pass through untouched.
+  private static final String OPEN_STRUCT_KEY_EXPORTED = 
"clicks.v2$promo%22code";
+  private static final String LABEL_KEY_COLUMN = "column";
+  private static final String LABEL_KEY_KEY = "key";
+
+  private static final List<ServerGauge> GAUGES_ACCEPTING_OPEN_STRUCT_COLUMN =
+      List.of(ServerGauge.OPEN_STRUCT_LAST_SEGMENT_DENSE_KEY_COUNT,
+          ServerGauge.OPEN_STRUCT_LAST_SEGMENT_SPARSE_KEY_COUNT,
+          ServerGauge.OPEN_STRUCT_LAST_SEGMENT_KEY_COUNT, 
ServerGauge.OPEN_STRUCT_LAST_SEGMENT_DOC_COUNT);
+
+  private static final List<ServerMeter> METERS_ACCEPTING_OPEN_STRUCT_COLUMN =
+      List.of(ServerMeter.OPEN_STRUCT_TYPE_COERCION_FAILURES, 
ServerMeter.OPEN_STRUCT_TYPE_INFERENCE_FAILURES);
+
+  private static final List<String> TABLENAME_TABLETYPE_COLUMN =
+      List.of(ExportedLabelKeys.TABLE, ExportedLabelValues.TABLENAME, 
ExportedLabelKeys.TABLETYPE,
+          ExportedLabelValues.TABLETYPE_REALTIME, LABEL_KEY_COLUMN, 
OPEN_STRUCT_COLUMN);
+
+  private static final List<String> TABLENAME_TABLETYPE_COLUMN_KEY =
+      List.of(ExportedLabelKeys.TABLE, ExportedLabelValues.TABLENAME, 
ExportedLabelKeys.TABLETYPE,
+          ExportedLabelValues.TABLETYPE_REALTIME, LABEL_KEY_COLUMN, 
OPEN_STRUCT_COLUMN, LABEL_KEY_KEY,
+          OPEN_STRUCT_KEY_EXPORTED);
+
   // pinot.mse.* metrics share the role-agnostic prefix and must be exported 
from every JVM role
   // that registers MseMetrics; on server JVMs this exercises the server.yml 
catch-all rule.
   private static final String EXPORTED_MSE_METRIC_PREFIX = "pinot_mse_";
@@ -118,6 +156,9 @@ public abstract class ServerPrometheusMetricsTest extends 
PinotPrometheusMetrics
       } else if (METERS_ACCEPTING_RAW_TABLE_NAMES.contains(serverMeter)) {
         addMeterWithLabels(serverMeter, ExportedLabelValues.TABLENAME);
         assertMeterExportedCorrectly(serverMeter.getMeterName(), 
ExportedLabels.TABLENAME);
+      } else if (METERS_ACCEPTING_OPEN_STRUCT_COLUMN.contains(serverMeter)) {
+        _serverMetrics.addMeteredTableValue(TABLE_NAME_WITH_TYPE, 
OPEN_STRUCT_COLUMN, serverMeter, 4L);
+        assertMeterExportedCorrectly(serverMeter.getMeterName(), 
TABLENAME_TABLETYPE_COLUMN);
       } else {
         //we pass tableNameWithType to all remaining meters
         addMeterWithLabels(serverMeter, TABLE_NAME_WITH_TYPE);
@@ -143,6 +184,14 @@ public abstract class ServerPrometheusMetricsTest extends 
PinotPrometheusMetrics
       } else if (GAUGES_ACCEPTING_RAW_TABLE_NAME.contains(serverGauge)) {
         addGaugeWithLabels(serverGauge, ExportedLabelValues.TABLENAME);
         assertGaugeExportedCorrectly(serverGauge.getGaugeName(), 
ExportedLabels.TABLENAME, EXPORTED_METRIC_PREFIX);
+      } else if (serverGauge == 
ServerGauge.OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT) {
+        _serverMetrics.setOrUpdateTableGauge(TABLE_NAME_WITH_TYPE,
+            OpenStructNaming.metricKey(OPEN_STRUCT_COLUMN, OPEN_STRUCT_KEY), 
serverGauge, 100L);
+        assertGaugeExportedCorrectly(serverGauge.getGaugeName(), 
TABLENAME_TABLETYPE_COLUMN_KEY,
+            EXPORTED_METRIC_PREFIX);
+      } else if (GAUGES_ACCEPTING_OPEN_STRUCT_COLUMN.contains(serverGauge)) {
+        _serverMetrics.setOrUpdateTableGauge(TABLE_NAME_WITH_TYPE, 
OPEN_STRUCT_COLUMN, serverGauge, 100L);
+        assertGaugeExportedCorrectly(serverGauge.getGaugeName(), 
TABLENAME_TABLETYPE_COLUMN, EXPORTED_METRIC_PREFIX);
       } else {
         addGaugeWithLabels(serverGauge, TABLE_NAME_WITH_TYPE);
         assertGaugeExportedCorrectly(serverGauge.getGaugeName(), 
ExportedLabels.TABLENAME_TABLETYPE,
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/indexsegment/mutable/MutableSegmentImpl.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/indexsegment/mutable/MutableSegmentImpl.java
index d9156565e49..681c546d418 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/indexsegment/mutable/MutableSegmentImpl.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/indexsegment/mutable/MutableSegmentImpl.java
@@ -427,7 +427,8 @@ public class MutableSegmentImpl implements MutableSegment {
       if (dataType == DataType.OPEN_STRUCT && fieldSpec instanceof 
ComplexFieldSpec) {
         IndexConfig openStructConfig = 
indexConfigs.getConfig(StandardIndexes.openStruct());
         if (openStructConfig instanceof OpenStructIndexConfig && 
openStructConfig.isEnabled()) {
-          MutableOpenStructIndex openStructIndex = new 
MutableOpenStructIndex(column, (ComplexFieldSpec) fieldSpec,
+          MutableOpenStructIndex openStructIndex = new 
MutableOpenStructIndex(column, _realtimeTableName,
+              (ComplexFieldSpec) fieldSpec,
               (OpenStructIndexConfig) openStructConfig, _memoryManager, 
_capacity);
           mutableIndexes.put(StandardIndexes.openStruct(), openStructIndex);
         }
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/openstruct/OpenStructColumnSplitter.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/openstruct/OpenStructColumnSplitter.java
index 00419a41907..2bdbf8a0e71 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/openstruct/OpenStructColumnSplitter.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/openstruct/OpenStructColumnSplitter.java
@@ -31,6 +31,7 @@ import java.util.Map;
 import java.util.Set;
 import javax.annotation.Nullable;
 import org.apache.commons.configuration2.PropertiesConfiguration;
+import org.apache.pinot.common.metrics.ServerGauge;
 import org.apache.pinot.common.metrics.ServerMeter;
 import org.apache.pinot.common.metrics.ServerMetrics;
 import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
@@ -87,6 +88,7 @@ public class OpenStructColumnSplitter implements 
ColumnarOpenStructIndexCreator
 
   private final File _indexDir;
   private final String _columnName;
+  private final String _tableNameWithType;
   private final Map<String, FieldSpec> _childFieldSpecs;
   private final OpenStructIndexConfig _config;
   private final int _maxDenseKeys;
@@ -95,18 +97,20 @@ public class OpenStructColumnSplitter implements 
ColumnarOpenStructIndexCreator
   private final Map<String, RoaringBitmap> _presenceBitmaps = new HashMap<>();
   private final Map<String, List<Object>> _values = new HashMap<>();
   private final Map<String, DataType> _inferredTypes = new HashMap<>();
+  private final Map<String, Long> _coercionFailuresPerKey = new HashMap<>();
+  private final Map<String, Long> _inferenceFailuresPerKey = new HashMap<>();
   private int _numDocs;
-  private int _coercionFailures;
 
   // Resolved at seal time
   @Nullable
   private Set<String> _resolvedDenseKeys;
   private final Map<String, PropertiesConfiguration> 
_materializedColumnMetadata = new LinkedHashMap<>();
 
-  public OpenStructColumnSplitter(File indexDir, String columnName, FieldSpec 
fieldSpec,
+  public OpenStructColumnSplitter(File indexDir, String columnName, String 
tableNameWithType, FieldSpec fieldSpec,
       OpenStructIndexConfig config) {
     _indexDir = indexDir;
     _columnName = columnName;
+    _tableNameWithType = tableNameWithType;
     _config = config;
     _maxDenseKeys = config.getMaxDenseKeys();
 
@@ -197,12 +201,34 @@ public class OpenStructColumnSplitter implements 
ColumnarOpenStructIndexCreator
           continue;
         }
         FieldSpec keySpec = _childFieldSpecs.get(key);
-        DataType valueType = keySpec != null
-            ? keySpec.getDataType()
-            : _inferredTypes.computeIfAbsent(key, k -> {
-              DataType inferred = 
OpenStructTypeInference.inferDataType(rawValue);
-              return inferred != null ? inferred : DataType.STRING;
-            });
+        DataType valueType;
+        if (keySpec != null) {
+          valueType = keySpec.getDataType();
+        } else {
+          DataType established = _inferredTypes.get(key);
+          if (established != null && established != DataType.STRING) {
+            // Sticky: a key already resolved to a non-STRING type can't flip 
later, so skip
+            // inference entirely -- matches MutableOpenStructIndex's fast 
path. An unmappable
+            // value here is a coercion failure below, not a fresh inference 
decision; overriding
+            // valueType to STRING per-row would desync it from _inferredTypes 
and corrupt _values
+            // with a mix of types for one key.
+            valueType = established;
+          } else {
+            // Resolve per value rather than only on first sighting: the key's 
inferred type is
+            // cached, so folding the counter into a computeIfAbsent would 
record one failure per
+            // key no matter how many values actually took the STRING fallback.
+            DataType inferred = 
OpenStructTypeInference.inferDataType(rawValue);
+            if (inferred == null) {
+              valueType = DataType.STRING;
+              _inferenceFailuresPerKey.merge(key, 1L, Long::sum);
+            } else {
+              // established is STRING here (or null): once a key falls back 
to STRING it stays
+              // STRING even if a later value would infer cleanly on its own.
+              valueType = established != null ? established : inferred;
+            }
+            _inferredTypes.putIfAbsent(key, valueType);
+          }
+        }
         if (!_presenceBitmaps.containsKey(key)) {
           _presenceBitmaps.put(key, new RoaringBitmap());
           _values.put(key, new ArrayList<>());
@@ -214,7 +240,7 @@ public class OpenStructColumnSplitter implements 
ColumnarOpenStructIndexCreator
           PinotDataType destType = 
ColumnDataType.fromDataTypeSV(valueType.getStoredType()).toPinotDataType();
           coerced = destType.convert(rawValue, sourceType);
         } catch (Exception e) {
-          _coercionFailures++;
+          _coercionFailuresPerKey.merge(key, 1L, Long::sum);
           _presenceBitmaps.get(key).remove(_numDocs);
           continue;
         }
@@ -246,17 +272,74 @@ public class OpenStructColumnSplitter implements 
ColumnarOpenStructIndexCreator
       writeSparseJsonColumn(sparseKeys);
     }
 
-    if (_coercionFailures > 0) {
-      LOGGER.info("OPEN_STRUCT '{}': dropped {} values due to type coercion 
failures", _columnName, _coercionFailures);
-      ServerMetrics serverMetrics = ServerMetrics.get();
-      if (serverMetrics != null) {
-        
serverMetrics.addMeteredGlobalValue(ServerMeter.OPEN_STRUCT_TYPE_COERCION_FAILURES,
 _coercionFailures);
-      }
+    long totalCoercionFailures = sumValues(_coercionFailuresPerKey);
+    if (totalCoercionFailures > 0) {
+      LOGGER.info("OPEN_STRUCT '{}': dropped {} values due to type coercion 
failures across {} keys",
+          _columnName, totalCoercionFailures, _coercionFailuresPerKey.size());
+      // The key space is user-controlled, so the per-key breakdown is 
DEBUG-only.
+      LOGGER.debug("OPEN_STRUCT '{}': full coercion failure counts: {}", 
_columnName, _coercionFailuresPerKey);
+    }
+    long totalInferenceFailures = sumValues(_inferenceFailuresPerKey);
+    if (totalInferenceFailures > 0) {
+      LOGGER.info("OPEN_STRUCT '{}': {} values across {} keys fell back to 
STRING after type inference failed",
+          _columnName, totalInferenceFailures, 
_inferenceFailuresPerKey.size());
+      LOGGER.debug("OPEN_STRUCT '{}': full inference failure counts: {}", 
_columnName, _inferenceFailuresPerKey);
     }
+    emitMetrics(sparseKeys.size(), totalCoercionFailures, 
totalInferenceFailures);
 
     emitParentColumnMetadata(sparseKeys);
   }
 
+  private static long sumValues(Map<String, Long> counts) {
+    return counts.values().stream().mapToLong(Long::longValue).sum();
+  }
+
+  private void emitMetrics(int sparseKeyCount, long totalCoercionFailures, 
long totalInferenceFailures) {
+    ServerMetrics serverMetrics = ServerMetrics.get();
+    if (serverMetrics == null || _numDocs == 0) {
+      return;
+    }
+
+    if (totalCoercionFailures > 0) {
+      serverMetrics.addMeteredTableValue(_tableNameWithType, _columnName,
+          ServerMeter.OPEN_STRUCT_TYPE_COERCION_FAILURES, 
totalCoercionFailures);
+    }
+    if (totalInferenceFailures > 0) {
+      serverMetrics.addMeteredTableValue(_tableNameWithType, _columnName,
+          ServerMeter.OPEN_STRUCT_TYPE_INFERENCE_FAILURES, 
totalInferenceFailures);
+    }
+
+    serverMetrics.setOrUpdateTableGauge(_tableNameWithType, _columnName,
+        ServerGauge.OPEN_STRUCT_LAST_SEGMENT_DENSE_KEY_COUNT, 
_resolvedDenseKeys.size());
+    serverMetrics.setOrUpdateTableGauge(_tableNameWithType, _columnName,
+        ServerGauge.OPEN_STRUCT_LAST_SEGMENT_SPARSE_KEY_COUNT, sparseKeyCount);
+    serverMetrics.setOrUpdateTableGauge(_tableNameWithType, _columnName,
+        ServerGauge.OPEN_STRUCT_LAST_SEGMENT_KEY_COUNT, 
_presenceBitmaps.size());
+    // Denominator for the per-key fill rate. Emitted as a raw count rather 
than folding the ratio into a
+    // single percentage gauge: integer division truncates a key present in a 
handful of docs to 0, which
+    // is indistinguishable from no data and is exactly the case worth 
alerting on.
+    serverMetrics.setOrUpdateTableGauge(_tableNameWithType, _columnName,
+        ServerGauge.OPEN_STRUCT_LAST_SEGMENT_DOC_COUNT, _numDocs);
+
+    if (_config.isPerKeyMetricsEnabled()) {
+      // Emit for every key in the segment. Registry entries follow the 
ingested key space;
+      // table deletion can only sweep keys recoverable from denseKeys.
+      _presenceBitmaps.forEach((key, presence) -> 
serverMetrics.setOrUpdateTableGauge(_tableNameWithType,
+          OpenStructNaming.metricKey(_columnName, key),
+          ServerGauge.OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT, 
presence.getCardinality()));
+    } else {
+      // Emit only for configured dense keys — bounded by the table config.
+      for (String key : _config.getDenseKeys()) {
+        RoaringBitmap presence = _presenceBitmaps.get(key);
+        if (presence != null) {
+          serverMetrics.setOrUpdateTableGauge(_tableNameWithType,
+              OpenStructNaming.metricKey(_columnName, key),
+              ServerGauge.OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT, 
presence.getCardinality());
+        }
+      }
+    }
+  }
+
   @Override
   public void close()
       throws IOException {
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/MutableOpenStructIndex.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/MutableOpenStructIndex.java
index f000232e0c0..648f0dd5ee9 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/MutableOpenStructIndex.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/MutableOpenStructIndex.java
@@ -57,6 +57,7 @@ public class MutableOpenStructIndex implements 
OpenStructIndexReader<ForwardInde
   private static final Logger LOGGER = 
LoggerFactory.getLogger(MutableOpenStructIndex.class);
 
   private final String _openStructColumn;
+  private final String _tableNameWithType;
   private final OpenStructIndexConfig _config;
   private final Map<String, FieldSpec> _childFieldSpecs;
   private final PinotDataBufferMemoryManager _memoryManager;
@@ -65,9 +66,10 @@ public class MutableOpenStructIndex implements 
OpenStructIndexReader<ForwardInde
   // Volatile for lock-free reader access; writer always holds the 
consuming-thread lock.
   private volatile Map<String, MutableKeyColumn> _keyColumns = new HashMap<>();
 
-  public MutableOpenStructIndex(String openStructColumn, ComplexFieldSpec 
fieldSpec,
+  public MutableOpenStructIndex(String openStructColumn, String 
tableNameWithType, ComplexFieldSpec fieldSpec,
       OpenStructIndexConfig config, PinotDataBufferMemoryManager 
memoryManager, int capacity) {
     _openStructColumn = openStructColumn;
+    _tableNameWithType = tableNameWithType;
     _config = config;
     _memoryManager = memoryManager;
     _capacity = capacity;
@@ -108,10 +110,7 @@ public class MutableOpenStructIndex implements 
OpenStructIndexReader<ForwardInde
         // build, so no key is dropped during consumption.
         // Resolve stored type and coerce BEFORE allocating a column so a 
first-row coercion failure
         // does not allocate a column that was never usable.
-        DataType resolvedType = resolveStoredType(key, rawValue);
-        if (resolvedType == null) {
-          continue;
-        }
+        DataType resolvedType = resolveStoredType(key, rawValue, null);
         Object coerced = tryCoerce(key, rawValue, resolvedType);
         if (coerced == null) {
           continue;
@@ -121,7 +120,9 @@ public class MutableOpenStructIndex implements 
OpenStructIndexReader<ForwardInde
         continue;
       }
 
-      DataType storedType = keyCol.getStoredType();
+      // Re-resolve against the established type so a later unmappable value 
on a STRING key is
+      // metered too; for any other established type this is a no-op returning 
that type.
+      DataType storedType = resolveStoredType(key, rawValue, 
keyCol.getStoredType());
       Object coerced = tryCoerce(key, rawValue, storedType);
       if (coerced == null) {
         continue;
@@ -130,29 +131,42 @@ public class MutableOpenStructIndex implements 
OpenStructIndexReader<ForwardInde
     }
   }
 
-  /// Resolves the stored type for a key without allocating any state. Returns 
null when the type
-  /// cannot be inferred (caller should skip the entry).
-  @Nullable
-  private DataType resolveStoredType(String key, Object rawValue) {
+  /// Resolves the stored type for a key without allocating any state, and 
meters a value that took
+  /// the STRING fallback. `establishedType` is the key's already-resolved 
stored type, or `null` on
+  /// first sighting.
+  ///
+  /// The fallback rule (unmappable value → STRING) must match the sealed 
build path
+  /// ([OpenStructColumnSplitter#addMap]) so a value reads the same before and 
after seal.
+  private DataType resolveStoredType(String key, Object rawValue, @Nullable 
DataType establishedType) {
     FieldSpec spec = _childFieldSpecs.get(key);
-    DataType valueType;
     if (spec != null) {
-      valueType = spec.getDataType();
-    } else {
-      valueType = OpenStructTypeInference.inferDataType(rawValue);
-      if (valueType == null) {
+      return spec.getDataType().getStoredType();
+    }
+    if (establishedType != null && establishedType != DataType.STRING) {
+      return establishedType;
+    }
+    DataType inferred = OpenStructTypeInference.inferDataType(rawValue);
+    if (inferred == null) {
+      if (establishedType == null) {
         LOGGER.warn("OPEN_STRUCT '{}': could not infer DataType for key '{}' 
from value of class '{}'."
-                + " Dropping the entry.",
+                + " Falling back to STRING.",
             _openStructColumn, key, rawValue.getClass().getName());
-        return null;
       }
+      ServerMetrics serverMetrics = ServerMetrics.get();
+      if (serverMetrics != null) {
+        serverMetrics.addMeteredTableValue(_tableNameWithType, 
_openStructColumn,
+            ServerMeter.OPEN_STRUCT_TYPE_INFERENCE_FAILURES, 1);
+      }
+      return DataType.STRING;
     }
-    return valueType.getStoredType();
+    return establishedType != null ? establishedType : inferred;
   }
 
-  /// Coerces rawValue to storedType. Returns null on failure (logged at 
WARN); the caller drops
-  /// the entry. Note: a successful coerce of a "null"-shaped raw value would 
also return null —
-  /// but callers gate on rawValue != null before reaching here.
+  /// Coerces rawValue to storedType. Returns null on failure; the caller 
drops the entry. Failures
+  /// are reported through [ServerMeter#OPEN_STRUCT_TYPE_COERCION_FAILURES] 
rather than a log line,
+  /// because this runs per value on the consuming path. Note: a successful 
coerce of a
+  /// "null"-shaped raw value would also return null — but callers gate on 
rawValue != null before
+  /// reaching here.
   @Nullable
   private Object tryCoerce(String key, Object rawValue, DataType storedType) {
     try {
@@ -162,7 +176,9 @@ public class MutableOpenStructIndex implements 
OpenStructIndexReader<ForwardInde
     } catch (Exception e) {
       ServerMetrics serverMetrics = ServerMetrics.get();
       if (serverMetrics != null) {
-        
serverMetrics.addMeteredGlobalValue(ServerMeter.OPEN_STRUCT_TYPE_COERCION_FAILURES,
 1);
+        // Column-granular for the same reason as the inference meter above.
+        serverMetrics.addMeteredTableValue(_tableNameWithType, 
_openStructColumn,
+            ServerMeter.OPEN_STRUCT_TYPE_COERCION_FAILURES, 1);
       }
       return null;
     }
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructIndexType.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructIndexType.java
index 787b383c12a..383975c1ba4 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructIndexType.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructIndexType.java
@@ -134,7 +134,8 @@ public class OpenStructIndexType
   public ColumnarOpenStructIndexCreator 
createIndexCreator(IndexCreationContext context,
       OpenStructIndexConfig indexConfig) {
     FieldSpec fieldSpec = context.getFieldSpec();
-    return new OpenStructColumnSplitter(context.getIndexDir(), 
fieldSpec.getName(), fieldSpec, indexConfig);
+    return new OpenStructColumnSplitter(context.getIndexDir(), 
fieldSpec.getName(), context.getTableNameWithType(),
+        fieldSpec, indexConfig);
   }
 
   @Override
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/creator/impl/openstruct/OpenStructColumnSplitterTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/creator/impl/openstruct/OpenStructColumnSplitterTest.java
index d8993af7da1..abb140a8b60 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/creator/impl/openstruct/OpenStructColumnSplitterTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/creator/impl/openstruct/OpenStructColumnSplitterTest.java
@@ -29,6 +29,9 @@ import java.util.Map;
 import java.util.Set;
 import org.apache.commons.configuration2.PropertiesConfiguration;
 import org.apache.commons.io.FileUtils;
+import org.apache.pinot.common.metrics.ServerGauge;
+import org.apache.pinot.common.metrics.ServerMeter;
+import org.apache.pinot.common.metrics.ServerMetrics;
 import org.apache.pinot.common.request.context.ExpressionContext;
 import org.apache.pinot.common.request.context.FilterContext;
 import org.apache.pinot.common.request.context.predicate.EqPredicate;
@@ -48,6 +51,12 @@ import org.testng.annotations.AfterMethod;
 import org.testng.annotations.BeforeMethod;
 import org.testng.annotations.Test;
 
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertFalse;
 import static org.testng.Assert.assertNotNull;
@@ -76,13 +85,19 @@ public class OpenStructColumnSplitterTest {
   }
 
   private OpenStructIndexConfig config(double minFillRate, int maxDenseKeys, 
Set<String> denseKeys) {
-    return new OpenStructIndexConfig(false, null, maxDenseKeys, denseKeys, 
minFillRate, null, null);
+    return config(minFillRate, maxDenseKeys, denseKeys, false);
+  }
+
+  private OpenStructIndexConfig config(double minFillRate, int maxDenseKeys, 
Set<String> denseKeys,
+      boolean perKeyMetricsEnabled) {
+    return new OpenStructIndexConfig(false, null, maxDenseKeys, denseKeys, 
minFillRate, null, null,
+        perKeyMetricsEnabled);
   }
 
   @Test
   public void testClassifyByFillRate()
       throws Exception {
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(),
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
         config(0.5, -1, null));
     for (int d = 0; d < 10; d++) {
       Map<String, Object> doc = d < 7 ? Map.of("clicks", (long) d) : Map.of();
@@ -95,7 +110,7 @@ public class OpenStructColumnSplitterTest {
   @Test
   public void testExplicitDenseKeysAlwaysMaterialized()
       throws Exception {
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(),
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
         config(0.99, -1, Set.of("rare")));
     s.add(Map.of("rare", "x"), 0);
     for (int d = 1; d < 100; d++) {
@@ -108,7 +123,7 @@ public class OpenStructColumnSplitterTest {
   @Test
   public void testRareKeyDroppedFromDense()
       throws Exception {
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(),
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
         config(0.5, -1, null));
     s.add(Map.of("rare", "x"), 0);
     for (int d = 1; d < 100; d++) {
@@ -121,7 +136,7 @@ public class OpenStructColumnSplitterTest {
   @Test
   public void testMaxDenseKeysCap()
       throws Exception {
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(),
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
         config(0.1, 1, null));
     for (int d = 0; d < 10; d++) {
       s.add(Map.of("a", "x", "b", "y", "c", "z"), d);
@@ -133,7 +148,7 @@ public class OpenStructColumnSplitterTest {
   @Test
   public void testZeroDocsIsNoop()
       throws Exception {
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(),
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
         config(0.5, -1, null));
     s.seal();
     assertTrue(s.getResolvedDenseKeys().isEmpty());
@@ -142,7 +157,7 @@ public class OpenStructColumnSplitterTest {
   @Test
   public void testSealEmitsParentMetadataForDense()
       throws Exception {
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(),
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
         config(0.5, -1, null));
     for (int d = 0; d < 10; d++) {
       s.add(Map.of("clicks", (long) d), d);
@@ -167,7 +182,7 @@ public class OpenStructColumnSplitterTest {
   @Test
   public void testDenseColumnMetadataKeysPresent()
       throws Exception {
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(),
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
         config(0.5, -1, null));
     for (int d = 0; d < 10; d++) {
       s.add(Map.of("clicks", (long) d), d);
@@ -194,7 +209,7 @@ public class OpenStructColumnSplitterTest {
   @Test
   public void testSparseJsonColumnWritten()
       throws Exception {
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(),
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
         config(0.9, -1, null));
     s.add(Map.of("rare", "x"), 0);
     for (int d = 1; d < 10; d++) {
@@ -210,7 +225,7 @@ public class OpenStructColumnSplitterTest {
       throws Exception {
     // Regression: an untyped key whose value is a BigDecimal used to crash 
seal() with
     // IllegalStateException("Unsupported OPEN_STRUCT stored type for 
dictionary build: BIG_DECIMAL").
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(),
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
         config(0.5, -1, null));
     for (int d = 0; d < 10; d++) {
       s.add(Map.of("amount", new 
BigDecimal("12.34").add(BigDecimal.valueOf(d))), d);
@@ -233,7 +248,7 @@ public class OpenStructColumnSplitterTest {
     // 1.0 and 1.00 are equal by compareTo but distinct by equals; they must 
stay separate dictionary
     // entries. Doc 2 is absent, so the default (BigDecimal.ZERO) is also 
collected -> 3 distinct values.
     // A compareTo-based dedup would wrongly collapse 1.0/1.00 and yield 2.
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(),
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
         config(0.5, -1, null));
     s.add(Map.of("amount", new BigDecimal("1.0")), 0);
     s.add(Map.of("amount", new BigDecimal("1.00")), 1);
@@ -254,7 +269,7 @@ public class OpenStructColumnSplitterTest {
     Map<String, FieldSpec> children = Map.of(
         "amount", new DimensionFieldSpec("amount", DataType.BIG_DECIMAL, 
true));
     ComplexFieldSpec specWithChild = new ComplexFieldSpec("metrics", 
DataType.OPEN_STRUCT, true, children);
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", specWithChild,
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", specWithChild,
         config(0.5, -1, null));
     for (int d = 0; d < 10; d++) {
       s.add(Map.of("amount", new BigDecimal("100.5")), d);
@@ -276,7 +291,7 @@ public class OpenStructColumnSplitterTest {
         .withEncodingType(FieldConfig.EncodingType.RAW).build();
     OpenStructIndexConfig cfg = new OpenStructIndexConfig(
         false, null, -1, null, 0.5, List.of(rawConfig), null);
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(), cfg);
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(), cfg);
     for (int d = 0; d < 10; d++) {
       s.add(Map.of("amount", new 
BigDecimal("7.5").add(BigDecimal.valueOf(d))), d);
     }
@@ -296,7 +311,7 @@ public class OpenStructColumnSplitterTest {
   public void testBigDecimalSparseKey()
       throws Exception {
     // A BIG_DECIMAL key below the fill-rate threshold goes to the sparse JSON 
column without crashing.
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(),
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
         config(0.9, -1, null));
     s.add(Map.of("rare", new BigDecimal("3.14159")), 0);
     for (int d = 1; d < 10; d++) {
@@ -311,7 +326,7 @@ public class OpenStructColumnSplitterTest {
       throws Exception {
     // Absent docs now store the standard Pinot dimension null value (INT -> 
Integer.MIN_VALUE),
     // so the column min reflects that default rather than the old 
metric-style 0.
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(),
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
         config(0.5, -1, null));
     for (int d = 0; d < 5; d++) {
       s.add(Map.of("clicks", 10 + d), d);   // present: 10..14
@@ -338,7 +353,7 @@ public class OpenStructColumnSplitterTest {
       throws Exception {
     // Default keys are dictionary-encoded with an inverted index (both 
default on), now written via the
     // standard ForwardIndexCreator and inverted index creator.
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(),
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
         config(0.5, -1, null));
     for (int d = 0; d < 10; d++) {
       s.add(Map.of("tag", "v" + (d % 3)), d);
@@ -364,7 +379,7 @@ public class OpenStructColumnSplitterTest {
     FieldConfig rawConfig = new FieldConfig.Builder("note")
         .withEncodingType(FieldConfig.EncodingType.RAW).build();
     OpenStructIndexConfig cfg = new OpenStructIndexConfig(false, null, -1, 
null, 0.5, List.of(rawConfig), null);
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(), cfg);
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(), cfg);
     for (int d = 0; d < 10; d++) {
       s.add(Map.of("note", "n" + d), d);
     }
@@ -387,7 +402,7 @@ public class OpenStructColumnSplitterTest {
     JsonNode indexes = JsonUtils.stringToJsonNode("{\"range\": {}, \"bloom\": 
{}}");
     FieldConfig keyConfig = new 
FieldConfig.Builder("clicks").withIndexes(indexes).build();
     OpenStructIndexConfig cfg = new OpenStructIndexConfig(false, null, -1, 
null, 0.5, List.of(keyConfig), null);
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(), cfg);
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(), cfg);
     for (int d = 0; d < 10; d++) {
       s.add(Map.of("clicks", d), d);
     }
@@ -412,7 +427,7 @@ public class OpenStructColumnSplitterTest {
         .withIndexes(indexes)
         .build();
     OpenStructIndexConfig cfg = new OpenStructIndexConfig(false, null, -1, 
null, 0.5, List.of(keyConfig), null);
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(), cfg);
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(), cfg);
     for (int d = 0; d < 10; d++) {
       s.add(Map.of("tag", "v" + d), d);
     }
@@ -424,7 +439,7 @@ public class OpenStructColumnSplitterTest {
   public void testParentMetadataCarriesSparseKeyManifest()
       throws Exception {
     // maxDenseKeys=0 forces every key sparse regardless of fill rate.
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(),
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
         config(0.5, 0, null));
     for (int d = 0; d < 10; d++) {
       s.add(Map.of("region", "us", "latencyMs", (long) d), d);
@@ -446,7 +461,7 @@ public class OpenStructColumnSplitterTest {
       throws Exception {
     // "clicks" is present on every doc (dense); "rare" is present on one doc 
(sparse). The manifest
     // must list only the sparse key, not the dense one.
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(),
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
         config(0.5, -1, null));
     s.add(Map.of("clicks", 1L, "rare", "x"), 0);
     for (int d = 1; d < 10; d++) {
@@ -467,7 +482,7 @@ public class OpenStructColumnSplitterTest {
   @Test
   public void testParentMetadataOmitsManifestWhenNoSparseKeys()
       throws Exception {
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(),
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
         config(0.5, -1, null));
     for (int d = 0; d < 10; d++) {
       s.add(Map.of("clicks", (long) d), d);
@@ -485,7 +500,7 @@ public class OpenStructColumnSplitterTest {
   @Test
   public void testParentMetadataManifestIncludesCommaKey()
       throws Exception {
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(),
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
         config(0.5, 0, null));
     for (int d = 0; d < 10; d++) {
       s.add(Map.of("region", "us", "weird,key", "x"), d);
@@ -505,7 +520,7 @@ public class OpenStructColumnSplitterTest {
       throws Exception {
     // Pins bare key form (not "$."-prefixed) — MapFilterOperator fast path 
relies on this.
     OpenStructIndexConfig cfg = new OpenStructIndexConfig(false, null, 0, 
null, null, null, true);
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(), cfg);
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(), cfg);
     s.add(Map.of("region", "us"), 0);
     s.add(Map.of(), 1);
     s.add(Map.of("region", "eu"), 2);
@@ -538,7 +553,7 @@ public class OpenStructColumnSplitterTest {
   public void testSparseJsonIndexAbsentByDefault()
       throws Exception {
     OpenStructIndexConfig cfg = new OpenStructIndexConfig(false, null, 0, 
null, null, null, null);
-    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", spec(), cfg);
+    OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(), cfg);
     s.add(Map.of("region", "us"), 0);
     s.add(Map.of(), 1);
     s.add(Map.of("region", "eu"), 2);
@@ -548,4 +563,233 @@ public class OpenStructColumnSplitterTest {
     File indexFile = new File(_tempDir, sparseCol + 
V1Constants.Indexes.JSON_INDEX_FILE_EXTENSION);
     assertFalse(indexFile.exists());
   }
+
+  /// The inferred type of a key is cached on first sighting, so counting 
inference failures inside
+  /// that caching step would record 1 failure per key no matter how many 
values actually failed.
+  /// Every value that takes the STRING fallback must be counted.
+  @Test
+  public void testInferenceFailuresCountedPerValueNotPerKey()
+      throws Exception {
+    ServerMetrics metrics = mock(ServerMetrics.class);
+    assertTrue(ServerMetrics.register(metrics), "another ServerMetrics is 
already registered");
+    try {
+      OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
+          config(0.5, -1, null));
+      // 'payload' has no child spec and a Map value, which 
OpenStructTypeInference cannot map to a DataType.
+      for (int d = 0; d < 4; d++) {
+        s.add(Map.of("payload", Map.of("nested", d)), d);
+      }
+      s.seal();
+
+      verify(metrics).addMeteredTableValue("testTable_OFFLINE", "metrics",
+          ServerMeter.OPEN_STRUCT_TYPE_INFERENCE_FAILURES, 4L);
+    } finally {
+      ServerMetrics.deregister();
+    }
+  }
+
+  /// A value that cannot be mapped to a DataType but lands on a key whose 
type is already
+  /// established as something other than STRING is dropped by coercion, not 
stored as STRING. It
+  /// must be counted once, against the coercion meter only — counting it as 
an inference failure
+  /// too would record one dropped value against two meters and make the 
seal-time log claim it
+  /// "fell back to STRING" when it did not.
+  @Test
+  public void testUnmappableValueOnTypedKeyCountsOnlyAsCoercionFailure()
+      throws Exception {
+    ServerMetrics metrics = mock(ServerMetrics.class);
+    assertTrue(ServerMetrics.register(metrics), "another ServerMetrics is 
already registered");
+    try {
+      OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
+          config(0.5, -1, null));
+      // First value fixes the key's type as LONG; the next two cannot be 
coerced to it.
+      s.add(Map.of("clicks", 1L), 0);
+      s.add(Map.of("clicks", Map.of("a", 1)), 1);
+      s.add(Map.of("clicks", List.of(1, 2)), 2);
+      s.seal();
+
+      verify(metrics).addMeteredTableValue("testTable_OFFLINE", "metrics",
+          ServerMeter.OPEN_STRUCT_TYPE_COERCION_FAILURES, 2L);
+      verify(metrics, never()).addMeteredTableValue(anyString(), anyString(),
+          eq(ServerMeter.OPEN_STRUCT_TYPE_INFERENCE_FAILURES), anyLong());
+    } finally {
+      ServerMetrics.deregister();
+    }
+  }
+
+  /// Fill rate is emitted as two raw counts rather than one percentage gauge: 
integer division
+  /// truncates a dense key present in a handful of docs to 0, which is 
indistinguishable from no
+  /// data and is exactly the case worth alerting on. Pins both numerator and 
denominator for a key
+  /// whose fill rate would truncate to 0.
+  @Test
+  public void testGaugesEmitRawDocCountsForTruncatingFillRate()
+      throws Exception {
+    ServerMetrics metrics = mock(ServerMetrics.class);
+    assertTrue(ServerMetrics.register(metrics), "another ServerMetrics is 
already registered");
+    try {
+      // 'rare' is forced dense despite appearing in 3 of 200 docs (1.5%, 
which truncates to 0%).
+      OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
+          config(0.5, -1, Set.of("rare")));
+      for (int d = 0; d < 200; d++) {
+        s.add(d < 3 ? Map.of("rare", (long) d, "host", "h") : Map.of("host", 
"h"), d);
+      }
+      s.classify();
+      s.seal();
+
+      verify(metrics).setOrUpdateTableGauge("testTable_OFFLINE", "metrics",
+          ServerGauge.OPEN_STRUCT_LAST_SEGMENT_DOC_COUNT, 200L);
+      verify(metrics).setOrUpdateTableGauge("testTable_OFFLINE",
+          OpenStructNaming.materializedColumnName("metrics", "rare"),
+          ServerGauge.OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT, 3L);
+      verify(metrics).setOrUpdateTableGauge("testTable_OFFLINE", "metrics",
+          ServerGauge.OPEN_STRUCT_LAST_SEGMENT_KEY_COUNT, 2L);
+      verify(metrics).setOrUpdateTableGauge("testTable_OFFLINE", "metrics",
+          ServerGauge.OPEN_STRUCT_LAST_SEGMENT_DENSE_KEY_COUNT, 2L);
+      verify(metrics).setOrUpdateTableGauge("testTable_OFFLINE", "metrics",
+          ServerGauge.OPEN_STRUCT_LAST_SEGMENT_SPARSE_KEY_COUNT, 0L);
+    } finally {
+      ServerMetrics.deregister();
+    }
+  }
+
+  /// Default: flag off, no configured denseKeys → no per-key gauge at all.
+  @Test
+  public void testPerKeyGaugeOffByDefaultWithNoDenseKeys()
+      throws Exception {
+    ServerMetrics metrics = mock(ServerMetrics.class);
+    assertTrue(ServerMetrics.register(metrics), "another ServerMetrics is 
already registered");
+    try {
+      OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
+          config(0.5, -1, null));
+      for (int d = 0; d < 10; d++) {
+        s.add(Map.of("host", "h", "clicks", (long) d), d);
+      }
+      s.classify();
+      s.seal();
+
+      verify(metrics, never()).setOrUpdateTableGauge(anyString(), anyString(),
+          eq(ServerGauge.OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT), anyLong());
+      // Column-level gauges still emit.
+      verify(metrics).setOrUpdateTableGauge("testTable_OFFLINE", "metrics",
+          ServerGauge.OPEN_STRUCT_LAST_SEGMENT_DENSE_KEY_COUNT, 2L);
+    } finally {
+      ServerMetrics.deregister();
+    }
+  }
+
+  /// Flag on: every discovered key emits, including sparse ones.
+  @Test
+  public void testPerKeyGaugeCoversAllKeysWhenEnabled()
+      throws Exception {
+    ServerMetrics metrics = mock(ServerMetrics.class);
+    assertTrue(ServerMetrics.register(metrics), "another ServerMetrics is 
already registered");
+    try {
+      OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
+          config(0.5, -1, null, true));
+      // 'host' fills every doc (dense); 'rare' fills 2 of 10 (sparse).
+      for (int d = 0; d < 10; d++) {
+        s.add(d < 2 ? Map.of("host", "h", "rare", (long) d) : Map.of("host", 
"h"), d);
+      }
+      s.classify();
+      s.seal();
+
+      verify(metrics).setOrUpdateTableGauge("testTable_OFFLINE", 
OpenStructNaming.metricKey("metrics", "host"),
+          ServerGauge.OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT, 10L);
+      verify(metrics).setOrUpdateTableGauge("testTable_OFFLINE", 
OpenStructNaming.metricKey("metrics", "rare"),
+          ServerGauge.OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT, 2L);
+      verify(metrics).setOrUpdateTableGauge("testTable_OFFLINE", "metrics",
+          ServerGauge.OPEN_STRUCT_LAST_SEGMENT_SPARSE_KEY_COUNT, 1L);
+    } finally {
+      ServerMetrics.deregister();
+    }
+  }
+
+  /// A configured key cut from the dense set by the maxDenseKeys cap still 
reports its doc count -- a
+  /// configured key that did not earn a materialized column is the case worth 
seeing. This is the branch
+  /// where iterating the keys present in the segment diverges from iterating 
`_resolvedDenseKeys`.
+  @Test
+  public void testPerKeyGaugeCoversConfiguredKeyCutByMaxDenseKeysCap()
+      throws Exception {
+    ServerMetrics metrics = mock(ServerMetrics.class);
+    assertTrue(ServerMetrics.register(metrics), "another ServerMetrics is 
already registered");
+    try {
+      // Both keys qualify on fill rate (100%), but the cap admits only one to 
the dense set.
+      OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
+          config(0.5, 1, Set.of("alpha", "beta")));
+      for (int d = 0; d < 10; d++) {
+        s.add(Map.of("alpha", (long) d, "beta", (long) d), d);
+      }
+      // Which of the two wins the single slot depends on Set.of iteration 
order, so assert the count
+      // rather than the identity; the gauge assertions below hold either way.
+      assertEquals(s.classify().size(), 1);
+      s.seal();
+
+      for (String key : List.of("alpha", "beta")) {
+        verify(metrics).setOrUpdateTableGauge("testTable_OFFLINE",
+            OpenStructNaming.materializedColumnName("metrics", key),
+            ServerGauge.OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT, 10L);
+      }
+    } finally {
+      ServerMetrics.deregister();
+    }
+  }
+
+  /// A configured key that never appears in the segment has no presence 
bitmap and is skipped rather
+  /// than reported as 0, which would be indistinguishable from a key that was 
ingested into no docs.
+  @Test
+  public void testPerKeyGaugeSkipsConfiguredKeyAbsentFromSegment()
+      throws Exception {
+    ServerMetrics metrics = mock(ServerMetrics.class);
+    assertTrue(ServerMetrics.register(metrics), "another ServerMetrics is 
already registered");
+    try {
+      OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
+          config(0.5, -1, Set.of("host", "never-ingested")));
+      for (int d = 0; d < 10; d++) {
+        s.add(Map.of("host", "h"), d);
+      }
+      s.classify();
+      s.seal();
+
+      verify(metrics, never()).setOrUpdateTableGauge(
+          eq("testTable_OFFLINE"), eq(OpenStructNaming.metricKey("metrics", 
"never-ingested")),
+          eq(ServerGauge.OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT), anyLong());
+      // The configured key that was ingested still reports, so the assertion 
above is about
+      // absence, not about the gauge being off entirely.
+      verify(metrics).setOrUpdateTableGauge("testTable_OFFLINE", 
OpenStructNaming.metricKey("metrics", "host"),
+          ServerGauge.OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT, 10L);
+    } finally {
+      ServerMetrics.deregister();
+    }
+  }
+
+  /// Pins the emission path to `metricKey`, not `materializedColumnName`. 
OPEN_STRUCT keys come from
+  /// user JSON, and a '"' in one is backslash-escaped by ObjectName.quote on 
the way to JMX, which stops
+  /// the exported name matching the per-key scrape rule at all -- the key 
silently loses its column/key
+  /// labels. Every other per-key test above uses a clean key, for which the 
two helpers agree, so this is
+  /// the only case here that fails if the call site regresses.
+  @Test
+  public void testPerKeyGaugeEscapesKeyForMetricName()
+      throws Exception {
+    String rawKey = "promo\"code";
+    ServerMetrics metrics = mock(ServerMetrics.class);
+    assertTrue(ServerMetrics.register(metrics), "another ServerMetrics is 
already registered");
+    try {
+      OpenStructColumnSplitter s = new OpenStructColumnSplitter(_tempDir, 
"metrics", "testTable_OFFLINE", spec(),
+          config(0.5, -1, Set.of(rawKey)));
+      for (int d = 0; d < 10; d++) {
+        s.add(Map.of("host", "h", rawKey, (long) d), d);
+      }
+      s.classify();
+      s.seal();
+
+      verify(metrics).setOrUpdateTableGauge("testTable_OFFLINE",
+          OpenStructNaming.metricKey("metrics", rawKey),
+          ServerGauge.OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT, 10L);
+      // The unescaped form is what the old call site produced; it must not be 
emitted.
+      verify(metrics, never()).setOrUpdateTableGauge(
+          eq("testTable_OFFLINE"), 
eq(OpenStructNaming.materializedColumnName("metrics", rawKey)),
+          eq(ServerGauge.OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT), anyLong());
+    } finally {
+      ServerMetrics.deregister();
+    }
+  }
 }
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/openstruct/MutableOpenStructDataSourceTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/openstruct/MutableOpenStructDataSourceTest.java
index 99388c083e4..271490e850b 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/openstruct/MutableOpenStructDataSourceTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/openstruct/MutableOpenStructDataSourceTest.java
@@ -66,7 +66,7 @@ public class MutableOpenStructDataSourceTest {
   @Test
   public void testGetDataSourcePerKey()
       throws Exception {
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
spec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", spec(),
         OpenStructIndexConfig.DEFAULT, _mm, 100)) {
       idx.index(0, Map.of("clicks", 5L));
       MutableOpenStructDataSource ds = new MutableOpenStructDataSource(spec(), 
idx, 1);
@@ -80,7 +80,7 @@ public class MutableOpenStructDataSourceTest {
   @Test
   public void testGetDataSourceForUnknownKey()
       throws Exception {
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
spec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", spec(),
         OpenStructIndexConfig.DEFAULT, _mm, 100)) {
       MutableOpenStructDataSource ds = new MutableOpenStructDataSource(spec(), 
idx, 0);
       assertNull(ds.getDataSource("missing"));
@@ -91,7 +91,7 @@ public class MutableOpenStructDataSourceTest {
   @Test
   public void testGetDataSourcesReturnsAllKeys()
       throws Exception {
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
spec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", spec(),
         OpenStructIndexConfig.DEFAULT, _mm, 100)) {
       idx.index(0, Map.of("clicks", 5L, "country", "US"));
       MutableOpenStructDataSource ds = new MutableOpenStructDataSource(spec(), 
idx, 1);
@@ -103,7 +103,7 @@ public class MutableOpenStructDataSourceTest {
   @Test
   public void testGetDataSourceIsMemoisedPerKey()
       throws Exception {
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
spec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", spec(),
         OpenStructIndexConfig.DEFAULT, _mm, 100)) {
       idx.index(0, Map.of("clicks", 5L));
       MutableOpenStructDataSource ds = new MutableOpenStructDataSource(spec(), 
idx, 1);
@@ -117,7 +117,7 @@ public class MutableOpenStructDataSourceTest {
   @Test
   public void testMemoisedDataSourceStaysCorrectAsIngestionContinues()
       throws Exception {
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
spec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", spec(),
         OpenStructIndexConfig.DEFAULT, _mm, 100)) {
       idx.index(0, Map.of("clicks", 5L));
       MutableOpenStructDataSource ds = new MutableOpenStructDataSource(spec(), 
idx, 1);
@@ -137,7 +137,7 @@ public class MutableOpenStructDataSourceTest {
   @Test
   public void testMemoisedNullBitmapReportsAbsentDocs()
       throws Exception {
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
spec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", spec(),
         OpenStructIndexConfig.DEFAULT, _mm, 100)) {
       idx.index(0, Map.of("country", "US"));
       idx.index(1, Map.of("clicks", 5L));
@@ -152,7 +152,7 @@ public class MutableOpenStructDataSourceTest {
   @Test
   public void testKeyCreatedAfterFirstLookupIsPickedUp()
       throws Exception {
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
spec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", spec(),
         OpenStructIndexConfig.DEFAULT, _mm, 100)) {
       MutableOpenStructDataSource ds = new MutableOpenStructDataSource(spec(), 
idx, 1);
       assertNull(ds.getDataSource("clicks"));
@@ -164,7 +164,7 @@ public class MutableOpenStructDataSourceTest {
   @Test
   public void testDictionaryReservesDefaultNullValueAtDictIdZero()
       throws Exception {
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
spec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", spec(),
         OpenStructIndexConfig.DEFAULT, _mm, 100)) {
       idx.index(0, Map.of("clicks", 5L));
       MutableOpenStructDataSource ds = new MutableOpenStructDataSource(spec(), 
idx, 1);
@@ -186,7 +186,7 @@ public class MutableOpenStructDataSourceTest {
   @Test
   public void testLastIndexedDocIdWatermark()
       throws Exception {
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
spec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", spec(),
         OpenStructIndexConfig.DEFAULT, _mm, 100)) {
       idx.index(0, Map.of("clicks", 5L));
       idx.index(1, Map.of("other", 1L));
@@ -201,7 +201,7 @@ public class MutableOpenStructDataSourceTest {
   @Test
   public void testForwardIndexSafeForAbsentTailDocs()
       throws Exception {
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
spec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", spec(),
         OpenStructIndexConfig.DEFAULT, _mm, 3000)) {
       idx.index(0, Map.of("clicks", 5L));
       idx.index(3, Map.of("clicks", 9L));
@@ -245,7 +245,7 @@ public class MutableOpenStructDataSourceTest {
   @Test
   public void testInvertedIndexFoldsAbsentDocsIntoDefaultPostings()
       throws Exception {
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
spec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", spec(),
         OpenStructIndexConfig.DEFAULT, _mm, 100)) {
       // Present in docs 0 (5L) and 3 (9L) of 6; docs 1,2,4,5 never see the 
key.
       idx.index(0, Map.of("clicks", 5L));
@@ -266,7 +266,7 @@ public class MutableOpenStructDataSourceTest {
   @Test
   public void testInvertedIndexFoldsAbsentDocsWithExplicitDefaultWrite()
       throws Exception {
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
spec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", spec(),
         OpenStructIndexConfig.DEFAULT, _mm, 100)) {
       // doc 0 writes a real value; doc 3 explicitly writes the LONG default 
itself.
       idx.index(0, Map.of("clicks", 5L));
@@ -284,7 +284,7 @@ public class MutableOpenStructDataSourceTest {
   @Test
   public void testKeyDictionaryExactWhenPartiallyPresent()
       throws Exception {
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
spec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", spec(),
         OpenStructIndexConfig.DEFAULT, _mm, 100)) {
       idx.index(0, Map.of("clicks", 5L));
       MutableOpenStructDataSource ds = new MutableOpenStructDataSource(spec(), 
idx, 3);
@@ -297,7 +297,7 @@ public class MutableOpenStructDataSourceTest {
   @Test
   public void testKeyDictionaryNotExactWhenFullyPresentAndDefaultUnobserved()
       throws Exception {
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
spec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", spec(),
         OpenStructIndexConfig.DEFAULT, _mm, 100)) {
       idx.index(0, Map.of("clicks", 5L));
       idx.index(1, Map.of("clicks", 7L));
@@ -310,7 +310,7 @@ public class MutableOpenStructDataSourceTest {
   @Test
   public void testKeyDictionaryExactWhenFullyPresentAndDefaultObserved()
       throws Exception {
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
spec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", spec(),
         OpenStructIndexConfig.DEFAULT, _mm, 100)) {
       idx.index(0, Map.of("clicks", 5L));
       idx.index(1, Map.of("clicks", Long.MIN_VALUE));
@@ -332,7 +332,7 @@ public class MutableOpenStructDataSourceTest {
     children.put("clicks", new DimensionFieldSpec("clicks", DataType.LONG, 
true, 0L));
     ComplexFieldSpec specWithCustomDefault =
         new ComplexFieldSpec("metrics", DataType.OPEN_STRUCT, true, children);
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
specWithCustomDefault,
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", specWithCustomDefault,
         OpenStructIndexConfig.DEFAULT, _mm, 100)) {
       idx.index(0, Map.of("clicks", 5L));
       MutableKeyColumn col = idx.getKeyColumn("clicks");
@@ -357,7 +357,7 @@ public class MutableOpenStructDataSourceTest {
   @Test(dataProvider = "storedTypes")
   public void testDefaultReservationPerStoredType(DataType storedType, Object 
value)
       throws Exception {
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
spec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", spec(),
         OpenStructIndexConfig.DEFAULT, _mm, 100)) {
       idx.index(0, Map.of("k", value));
       MutableKeyColumn col = idx.getKeyColumn("k");
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/openstruct/MutableOpenStructIndexTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/openstruct/MutableOpenStructIndexTest.java
index fc031f04df0..492cbeac33e 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/openstruct/MutableOpenStructIndexTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/openstruct/MutableOpenStructIndexTest.java
@@ -21,6 +21,8 @@ package 
org.apache.pinot.segment.local.segment.index.openstruct;
 import java.io.IOException;
 import java.util.Map;
 import java.util.Set;
+import org.apache.pinot.common.metrics.ServerMeter;
+import org.apache.pinot.common.metrics.ServerMetrics;
 import org.apache.pinot.segment.local.io.writer.impl.DirectMemoryManager;
 import org.apache.pinot.segment.spi.memory.PinotDataBufferMemoryManager;
 import org.apache.pinot.spi.config.table.OpenStructIndexConfig;
@@ -30,6 +32,14 @@ import org.testng.annotations.AfterMethod;
 import org.testng.annotations.BeforeMethod;
 import org.testng.annotations.Test;
 
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertNotNull;
 import static org.testng.Assert.assertNull;
@@ -58,7 +68,7 @@ public class MutableOpenStructIndexTest {
   public void testAddAndGetKeys()
       throws IOException {
     try (MutableOpenStructIndex idx = new MutableOpenStructIndex(
-        "metrics", openStructSpec(), OpenStructIndexConfig.DEFAULT, _memMgr, 
1000)) {
+        "metrics", "testTable_REALTIME", openStructSpec(), 
OpenStructIndexConfig.DEFAULT, _memMgr, 1000)) {
 
       idx.index(0, Map.of("clicks", 42L, "impressions", 100L));
       idx.index(1, Map.of("clicks", 7L, "revenue", "1.5"));
@@ -75,7 +85,7 @@ public class MutableOpenStructIndexTest {
   public void testIndexNullIsNoop()
       throws IOException {
     try (MutableOpenStructIndex idx = new MutableOpenStructIndex(
-        "metrics", openStructSpec(), OpenStructIndexConfig.DEFAULT, _memMgr, 
1000)) {
+        "metrics", "testTable_REALTIME", openStructSpec(), 
OpenStructIndexConfig.DEFAULT, _memMgr, 1000)) {
 
       idx.index(0, null);
 
@@ -88,7 +98,7 @@ public class MutableOpenStructIndexTest {
   public void testFillRateTracking()
       throws IOException {
     try (MutableOpenStructIndex idx = new MutableOpenStructIndex(
-        "metrics", openStructSpec(), OpenStructIndexConfig.DEFAULT, _memMgr, 
1000)) {
+        "metrics", "testTable_REALTIME", openStructSpec(), 
OpenStructIndexConfig.DEFAULT, _memMgr, 1000)) {
 
       for (int docId = 0; docId < 10; docId++) {
         if (docId < 7) {
@@ -111,7 +121,7 @@ public class MutableOpenStructIndexTest {
       throws IOException {
     // No childFieldSpecs — type inference from rawValue
     ComplexFieldSpec spec = new ComplexFieldSpec("metrics", 
DataType.OPEN_STRUCT, true, Map.of());
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
spec,
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", spec,
         OpenStructIndexConfig.DEFAULT, _memMgr, 100)) {
       idx.index(0, java.util.Map.of("clicks", 5L));
       assertEquals(idx.getKeyColumn("clicks").getStoredType(), DataType.LONG);
@@ -122,7 +132,7 @@ public class MutableOpenStructIndexTest {
 
   @Test
   public void testImplementsOpenStructIndexReader() throws Exception {
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
openStructSpec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", openStructSpec(),
         OpenStructIndexConfig.DEFAULT, _memMgr, 100)) {
       assertTrue(idx instanceof 
org.apache.pinot.segment.spi.index.reader.OpenStructIndexReader);
     }
@@ -130,7 +140,7 @@ public class MutableOpenStructIndexTest {
 
   @Test
   public void testGetIndexesReturnsForwardIndexForMaterializedKey() throws 
Exception {
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
openStructSpec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", openStructSpec(),
         OpenStructIndexConfig.DEFAULT, _memMgr, 100)) {
       idx.index(0, Map.of("clicks", 5L));
       Map<org.apache.pinot.segment.spi.index.IndexType, 
org.apache.pinot.segment.spi.index.IndexReader> indexes =
@@ -141,7 +151,7 @@ public class MutableOpenStructIndexTest {
 
   @Test
   public void testGetIndexesUnknownKeyReturnsEmpty() throws Exception {
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
openStructSpec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", openStructSpec(),
         OpenStructIndexConfig.DEFAULT, _memMgr, 100)) {
       assertTrue(idx.getIndexes("missing").isEmpty());
     }
@@ -149,7 +159,7 @@ public class MutableOpenStructIndexTest {
 
   @Test
   public void testGetColumnMetadataReturnsKeyMetadata() throws Exception {
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
openStructSpec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", openStructSpec(),
         OpenStructIndexConfig.DEFAULT, _memMgr, 100)) {
       idx.index(0, Map.of("clicks", 5L));
       assertNotNull(idx.getColumnMetadata("clicks"));
@@ -157,4 +167,50 @@ public class MutableOpenStructIndexTest {
       assertNull(idx.getColumnMetadata("absent"));
     }
   }
+
+  /// Both failure meters are keyed on the OPEN_STRUCT column, never on the 
per-key materialized
+  /// name. These fire on malformed input, so an id-like key would otherwise 
mint one meter per id,
+  /// and meters are never removed from the registry.
+  @Test
+  public void testFailureMetersAreKeyedOnColumnNotKey()
+      throws IOException {
+    ServerMetrics metrics = mock(ServerMetrics.class);
+    assertTrue(ServerMetrics.register(metrics), "another ServerMetrics is 
already registered");
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", openStructSpec(),
+        OpenStructIndexConfig.DEFAULT, _memMgr, 100)) {
+      // Unmappable value on a fresh key: falls back to STRING and meters an 
inference failure.
+      idx.index(0, Map.of("req-42", Map.of("a", 1)));
+      // Unmappable value on a key already typed LONG: dropped by coercion, 
metered there only.
+      idx.index(1, Map.of("clicks", 5L));
+      idx.index(2, Map.of("clicks", Map.of("a", 1)));
+
+      verify(metrics).addMeteredTableValue("testTable_REALTIME", "metrics",
+          ServerMeter.OPEN_STRUCT_TYPE_INFERENCE_FAILURES, 1L);
+      verify(metrics).addMeteredTableValue("testTable_REALTIME", "metrics",
+          ServerMeter.OPEN_STRUCT_TYPE_COERCION_FAILURES, 1L);
+      verify(metrics, never()).addMeteredTableValue(anyString(), 
eq("metrics$req-42"), any(), anyLong());
+      verify(metrics, never()).addMeteredTableValue(anyString(), 
eq("metrics$clicks"), any(), anyLong());
+    } finally {
+      ServerMetrics.deregister();
+    }
+  }
+
+  /// A later unmappable value on a key whose type fell back to STRING is 
stored as its serialized
+  /// form, so it is metered every time — not just on the first sighting that 
established the type.
+  @Test
+  public void testInferenceFailuresMeteredPerValueOnStringFallbackKey()
+      throws IOException {
+    ServerMetrics metrics = mock(ServerMetrics.class);
+    assertTrue(ServerMetrics.register(metrics), "another ServerMetrics is 
already registered");
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex("metrics", 
"testTable_REALTIME", openStructSpec(),
+        OpenStructIndexConfig.DEFAULT, _memMgr, 100)) {
+      for (int docId = 0; docId < 3; docId++) {
+        idx.index(docId, Map.of("payload", Map.of("a", docId)));
+      }
+      verify(metrics, times(3)).addMeteredTableValue("testTable_REALTIME", 
"metrics",
+          ServerMeter.OPEN_STRUCT_TYPE_INFERENCE_FAILURES, 1L);
+    } finally {
+      ServerMetrics.deregister();
+    }
+  }
 }
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructConsumingSealedParityTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructConsumingSealedParityTest.java
index db8ce5c48f1..1495973f410 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructConsumingSealedParityTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/openstruct/OpenStructConsumingSealedParityTest.java
@@ -116,7 +116,7 @@ public class OpenStructConsumingSealedParityTest {
     // --- Consuming side ---
     List<Object> consumingValues;
     MutableRoaringBitmap consumingDefaultDocIds;
-    try (MutableOpenStructIndex idx = new MutableOpenStructIndex(METRICS, 
spec(),
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex(METRICS, 
"testTable_REALTIME", spec(),
         OpenStructIndexConfig.DEFAULT, _mm, NUM_DOCS)) {
       for (int docId = 0; docId < NUM_DOCS; docId++) {
         idx.index(docId, metricsForDoc(docId));
@@ -201,6 +201,82 @@ public class OpenStructConsumingSealedParityTest {
     }
   }
 
+  /// A key with no declared child spec whose value is a Map or a List: 
`OpenStructTypeInference`
+  /// maps neither to a Pinot DataType, so both tiers must fall back to STRING 
and store the
+  /// serialized form. Before this was aligned, the consuming tier dropped the 
entry while the
+  /// sealed tier kept it, so the same row read differently either side of the 
seal boundary (and
+  /// the REALTIME and OFFLINE halves of a hybrid table disagreed).
+  @Test
+  public void testConsumingMatchesSealedForUninferrableValues()
+      throws Exception {
+    String nestedKey = "payload";
+    Map<Integer, Object> raw = new HashMap<>();
+    raw.put(0, Map.of("b", 2, "a", 1));
+    raw.put(1, List.of(1, 2, 3));
+    for (int docId = 2; docId < NUM_DOCS; docId++) {
+      raw.put(docId, "plain-" + docId);
+    }
+
+    // 'payload' is deliberately absent from the child specs so that type 
inference runs for it.
+    ComplexFieldSpec inferredSpec = new ComplexFieldSpec(METRICS, 
FieldSpec.DataType.OPEN_STRUCT, true,
+        Map.of("host", new DimensionFieldSpec("host", 
FieldSpec.DataType.STRING, true)));
+
+    List<Object> consumingValues;
+    try (MutableOpenStructIndex idx = new MutableOpenStructIndex(METRICS, 
"testTable_REALTIME", inferredSpec,
+        OpenStructIndexConfig.DEFAULT, _mm, NUM_DOCS)) {
+      for (int docId = 0; docId < NUM_DOCS; docId++) {
+        idx.index(docId, Map.of("host", "host-" + docId, nestedKey, 
raw.get(docId)));
+      }
+      MutableOpenStructDataSource ds = new 
MutableOpenStructDataSource(inferredSpec, idx, NUM_DOCS);
+      DataSource payload = ds.getDataSource(nestedKey);
+      assertNotNull(payload, "uninferrable key must still be materialized on 
the consuming side");
+      consumingValues = readAllValues(payload);
+    }
+
+    Schema schema = new 
Schema.SchemaBuilder().setSchemaName("testOpenStructInferParity")
+        .addField(inferredSpec)
+        .build();
+    OpenStructIndexConfig osConfig =
+        new OpenStructIndexConfig(false, null, -1, Set.of(nestedKey, "host"), 
0.5, List.of(), null);
+    ObjectNode indexes = JsonUtils.newObjectNode();
+    indexes.set("open_struct", JsonUtils.objectToJsonNode(osConfig));
+    FieldConfig metricsCfg = new 
FieldConfig.Builder(METRICS).withIndexes(indexes).build();
+    TableConfig tableConfig = new 
TableConfigBuilder(TableType.OFFLINE).setTableName("testOpenStructInferParity")
+        .setFieldConfigList(List.of(metricsCfg)).build();
+
+    SegmentGeneratorConfig config = new SegmentGeneratorConfig(tableConfig, 
schema);
+    config.setOutDir(TMP_DIR.getAbsolutePath());
+    config.setSegmentName("testSegmentInferParity");
+
+    List<GenericRow> rows = new ArrayList<>(NUM_DOCS);
+    for (int docId = 0; docId < NUM_DOCS; docId++) {
+      GenericRow row = new GenericRow();
+      row.putValue(METRICS, Map.of("host", "host-" + docId, nestedKey, 
raw.get(docId)));
+      rows.add(row);
+    }
+
+    SegmentIndexCreationDriverImpl driver = new 
SegmentIndexCreationDriverImpl();
+    driver.init(config, new GenericRowRecordReader(rows));
+    driver.build();
+
+    ImmutableSegment sealed = 
ImmutableSegmentLoader.load(driver.getOutputDirectory(), ReadMode.mmap);
+    try {
+      OpenStructDataSource sealedMetrics = (OpenStructDataSource) 
sealed.getDataSource(METRICS);
+      DataSource sealedPayload = sealedMetrics.getDataSource(nestedKey);
+      assertNotNull(sealedPayload, "uninferrable key must still be 
materialized on the sealed side");
+      List<Object> sealedValues = readAllValues(sealedPayload);
+
+      assertEquals(consumingValues, sealedValues);
+      // Pin the serialized form so a change in either tier's fallback is 
caught, not just divergence.
+      // MapUtils.toString sorts by key, so the input order {"b","a"} must 
come back out as {"a","b"}.
+      assertEquals(consumingValues.get(0), "{\"a\":1,\"b\":2}");
+      assertEquals(consumingValues.get(1), "[1, 2, 3]");
+      assertEquals(consumingValues.get(2), "plain-2");
+    } finally {
+      sealed.destroy();
+    }
+  }
+
   private static long min(List<Object> values) {
     return values.stream().mapToLong(v -> ((Number) 
v).longValue()).min().orElseThrow();
   }
diff --git 
a/pinot-server/src/main/java/org/apache/pinot/server/starter/helix/SegmentMessageHandlerFactory.java
 
b/pinot-server/src/main/java/org/apache/pinot/server/starter/helix/SegmentMessageHandlerFactory.java
index cf7498b0dee..9312477a3be 100644
--- 
a/pinot-server/src/main/java/org/apache/pinot/server/starter/helix/SegmentMessageHandlerFactory.java
+++ 
b/pinot-server/src/main/java/org/apache/pinot/server/starter/helix/SegmentMessageHandlerFactory.java
@@ -18,11 +18,14 @@
  */
 package org.apache.pinot.server.starter.helix;
 
+import com.google.common.annotations.VisibleForTesting;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Set;
 import org.apache.commons.collections4.CollectionUtils;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.tuple.Pair;
 import org.apache.helix.NotificationContext;
 import org.apache.helix.messaging.handling.HelixTaskResult;
 import org.apache.helix.messaging.handling.MessageHandler;
@@ -43,6 +46,13 @@ import org.apache.pinot.common.metrics.ServerTimer;
 import org.apache.pinot.core.data.manager.InstanceDataManager;
 import org.apache.pinot.core.data.manager.realtime.RealtimeTableDataManager;
 import org.apache.pinot.segment.local.data.manager.TableDataManager;
+import org.apache.pinot.segment.spi.index.FieldIndexConfigsUtil;
+import org.apache.pinot.segment.spi.index.StandardIndexes;
+import org.apache.pinot.spi.config.table.IndexConfig;
+import org.apache.pinot.spi.config.table.OpenStructIndexConfig;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.data.OpenStructNaming;
+import org.apache.pinot.spi.data.Schema;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -169,6 +179,9 @@ public class SegmentMessageHandlerFactory implements 
MessageHandlerFactory {
     public HelixTaskResult handleMessage() {
       HelixTaskResult helixTaskResult = new HelixTaskResult();
       _logger.info("Handling table deletion message: {}", _message);
+      // Resolved before the table goes away: these gauge keys come from the 
table config, and the only
+      // in-memory copy of it lives on the table data manager that deleteTable 
is about to discard.
+      List<String> openStructMetricKeys = resolveOpenStructMetricKeys();
       try {
         long deletionTimeMs = _message.getCreateTimeStamp();
         if (deletionTimeMs <= 0) {
@@ -188,6 +201,12 @@ public class SegmentMessageHandlerFactory implements 
MessageHandlerFactory {
         Arrays.stream(ServerGauge.values())
             .filter(g -> !g.isGlobal())
             .forEach(g -> _metrics.removeTableGauge(_tableNameWithType, g));
+        // OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT registers as 
<gauge>.<table>.<column>$<key>, so the sweep above --
+        // which composes only the unkeyed <gauge>.<table> -- cannot reach it. 
Only the configured keys are
+        // recoverable here; see openStructMetricKeys for the keys this misses.
+        openStructMetricKeys.forEach(
+            metricKey -> _metrics.removeTableGauge(_tableNameWithType, 
metricKey,
+                ServerGauge.OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT));
         Arrays.stream(ServerTimer.values())
             .filter(t -> !t.isGlobal())
             .forEach(t -> _metrics.removeTableTimer(_tableNameWithType, t));
@@ -199,6 +218,41 @@ public class SegmentMessageHandlerFactory implements 
MessageHandlerFactory {
       }
       return helixTaskResult;
     }
+
+    private List<String> resolveOpenStructMetricKeys() {
+      try {
+        TableDataManager tableDataManager = 
_instanceDataManager.getTableDataManager(_tableNameWithType);
+        if (tableDataManager == null) {
+          return List.of();
+        }
+        Pair<TableConfig, Schema> configAndSchema = 
tableDataManager.getCachedTableConfigAndSchema();
+        return configAndSchema == null ? List.of()
+            : openStructMetricKeys(configAndSchema.getLeft(), 
configAndSchema.getRight());
+      } catch (Exception e) {
+        // Metric bookkeeping must never block table deletion.
+        _logger.warn("Could not resolve OPEN_STRUCT gauge keys for table: {}; 
its per-key gauges may survive until "
+            + "the next restart", _tableNameWithType, e);
+        return List.of();
+      }
+    }
+  }
+
+  /// The `<column>$<key>` metric keys of the per-key OPEN_STRUCT gauges 
recoverable from the given
+  /// table's config — one per configured `denseKeys` entry. Complete when 
`perKeyMetricsEnabled` is
+  /// off; a subset when on, since discovered keys exist only in ingested data 
and cannot be named at
+  /// deletion time. Gauges for discovered keys survive until the server 
restarts.
+  @VisibleForTesting
+  static List<String> openStructMetricKeys(TableConfig tableConfig, Schema 
schema) {
+    List<String> metricKeys = new ArrayList<>();
+    FieldIndexConfigsUtil.createIndexConfigsByColName(tableConfig, 
schema).forEach((column, indexConfigs) -> {
+      IndexConfig openStructConfig = 
indexConfigs.getConfig(StandardIndexes.openStruct());
+      if (openStructConfig instanceof OpenStructIndexConfig) {
+        for (String key : ((OpenStructIndexConfig) 
openStructConfig).getDenseKeys()) {
+          metricKeys.add(OpenStructNaming.metricKey(column, key));
+        }
+      }
+    });
+    return metricKeys;
   }
 
   private class ForceCommitMessageHandler extends DefaultMessageHandler {
diff --git 
a/pinot-server/src/test/java/org/apache/pinot/server/starter/helix/SegmentMessageHandlerFactoryTest.java
 
b/pinot-server/src/test/java/org/apache/pinot/server/starter/helix/SegmentMessageHandlerFactoryTest.java
new file mode 100644
index 00000000000..af07798fddc
--- /dev/null
+++ 
b/pinot-server/src/test/java/org/apache/pinot/server/starter/helix/SegmentMessageHandlerFactoryTest.java
@@ -0,0 +1,188 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.server.starter.helix;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.lang3.tuple.Pair;
+import org.apache.pinot.common.messages.TableDeletionMessage;
+import org.apache.pinot.common.metrics.ServerGauge;
+import org.apache.pinot.common.metrics.ServerMetrics;
+import org.apache.pinot.core.data.manager.InstanceDataManager;
+import org.apache.pinot.segment.local.data.manager.TableDataManager;
+import org.apache.pinot.spi.config.table.FieldConfig;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.ComplexFieldSpec;
+import org.apache.pinot.spi.data.DimensionFieldSpec;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.utils.JsonUtils;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.mockito.InOrder;
+import org.testng.annotations.Test;
+
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+
+/// Covers the OPEN_STRUCT gauge keys resolved on table deletion. 
`OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT` is a
+/// keyed gauge, so the generic `removeTableGauge(table, gauge)` sweep cannot 
reach it; the handler
+/// re-derives its keys from the table config instead.
+public class SegmentMessageHandlerFactoryTest {
+
+  private static Schema schema() {
+    return new Schema.SchemaBuilder().setSchemaName("t")
+        .addField(new ComplexFieldSpec("metrics", DataType.OPEN_STRUCT, true, 
Map.of()))
+        .addField(new DimensionFieldSpec("plain", DataType.STRING, true))
+        .build();
+  }
+
+  private static TableConfig tableConfig(List<FieldConfig> fieldConfigs) {
+    return new 
TableConfigBuilder(TableType.OFFLINE).setTableName("t").setFieldConfigList(fieldConfigs).build();
+  }
+
+  private static FieldConfig openStructField(String column, String 
denseKeysJson)
+      throws Exception {
+    JsonNode indexes = JsonUtils.stringToJsonNode("{\"open_struct\": 
{\"denseKeys\": " + denseKeysJson + "}}");
+    return new FieldConfig.Builder(column).withIndexes(indexes).build();
+  }
+
+  @Test
+  public void testResolvesOneMetricKeyPerConfiguredDenseKey()
+      throws Exception {
+    TableConfig tableConfig = tableConfig(List.of(openStructField("metrics", 
"[\"clicks\", \"views\"]")));
+
+    List<String> metricKeys = 
SegmentMessageHandlerFactory.openStructMetricKeys(tableConfig, schema());
+
+    assertEquals(metricKeys.size(), 2);
+    assertTrue(metricKeys.contains("metrics$clicks"), metricKeys.toString());
+    assertTrue(metricKeys.contains("metrics$views"), metricKeys.toString());
+  }
+
+  /// The emitted gauge key is escaped for JMX, so the key used to remove it 
has to be escaped the
+  /// same way or the removal silently misses.
+  @Test
+  public void testMetricKeysAreEscapedLikeTheEmittedGauge()
+      throws Exception {
+    TableConfig tableConfig = tableConfig(List.of(openStructField("metrics", 
"[\"promo\\\"code\"]")));
+
+    List<String> metricKeys = 
SegmentMessageHandlerFactory.openStructMetricKeys(tableConfig, schema());
+
+    assertEquals(metricKeys, List.of("metrics$promo%22code"));
+  }
+
+  @Test
+  public void testNoKeysWithoutConfiguredDenseKeys()
+      throws Exception {
+    // No configured denseKeys → nothing recoverable for removal. When 
perKeyMetricsEnabled is on,
+    // discovered-key gauges are emitted but not reachable here; they survive 
until server restart.
+    TableConfig tableConfig = tableConfig(List.of(openStructField("metrics", 
"[]")));
+
+    assertTrue(SegmentMessageHandlerFactory.openStructMetricKeys(tableConfig, 
schema()).isEmpty());
+  }
+
+  @Test
+  public void testNoKeysForTableWithoutOpenStructColumns() {
+    Schema plainSchema = new Schema.SchemaBuilder().setSchemaName("t")
+        .addField(new DimensionFieldSpec("plain", DataType.STRING, true))
+        .build();
+
+    
assertTrue(SegmentMessageHandlerFactory.openStructMetricKeys(tableConfig(List.of()),
 plainSchema).isEmpty());
+  }
+
+  private static final String TABLE = "t_OFFLINE";
+
+  private static void runDeletion(InstanceDataManager instanceDataManager, 
ServerMetrics serverMetrics)
+      throws Exception {
+    new SegmentMessageHandlerFactory(instanceDataManager, 
serverMetrics).createHandler(
+        new TableDeletionMessage(TABLE), null).handleMessage();
+  }
+
+  private static InstanceDataManager 
instanceDataManagerReturning(Pair<TableConfig, Schema> cachedConfigAndSchema) {
+    TableDataManager tableDataManager = mock(TableDataManager.class);
+    
when(tableDataManager.getCachedTableConfigAndSchema()).thenReturn(cachedConfigAndSchema);
+    InstanceDataManager instanceDataManager = mock(InstanceDataManager.class);
+    
when(instanceDataManager.getTableDataManager(TABLE)).thenReturn(tableDataManager);
+    return instanceDataManager;
+  }
+
+  /// The reason this change exists: the generic sweep in the handler composes 
only the unkeyed
+  /// `<gauge>.<table>`, so without this targeted call the per-key gauge 
outlives the table. Asserts on
+  /// the keyed `removeTableGauge` overload rather than on a registry, because 
reading back a gauge
+  /// written by `setOrUpdateTableGauge` needs a metrics factory that 
pinot-server does not register.
+  @Test
+  public void testHandlerRemovesTheEmittedPerKeyGauge()
+      throws Exception {
+    ServerMetrics serverMetrics = mock(ServerMetrics.class);
+    TableConfig tableConfig = tableConfig(List.of(openStructField("metrics", 
"[\"clicks\"]")));
+
+    runDeletion(instanceDataManagerReturning(Pair.of(tableConfig, schema())), 
serverMetrics);
+
+    verify(serverMetrics).removeTableGauge(TABLE, "metrics$clicks", 
ServerGauge.OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT);
+  }
+
+  /// The keys have to be read off the table data manager before deleteTable 
discards it.
+  @Test
+  public void testKeysAreResolvedBeforeTheTableIsDeleted()
+      throws Exception {
+    ServerMetrics serverMetrics = mock(ServerMetrics.class);
+    TableConfig tableConfig = tableConfig(List.of(openStructField("metrics", 
"[\"clicks\"]")));
+    InstanceDataManager instanceDataManager = 
instanceDataManagerReturning(Pair.of(tableConfig, schema()));
+
+    runDeletion(instanceDataManager, serverMetrics);
+
+    InOrder inOrder = inOrder(instanceDataManager, serverMetrics);
+    inOrder.verify(instanceDataManager).getTableDataManager(TABLE);
+    inOrder.verify(instanceDataManager).deleteTable(eq(TABLE), anyLong());
+    inOrder.verify(serverMetrics)
+        .removeTableGauge(TABLE, "metrics$clicks", 
ServerGauge.OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT);
+  }
+
+  /// Metric bookkeeping must never block table deletion.
+  @Test
+  public void testDeletionProceedsWhenKeysCannotBeResolved()
+      throws Exception {
+    InstanceDataManager instanceDataManager = mock(InstanceDataManager.class);
+    when(instanceDataManager.getTableDataManager(TABLE)).thenThrow(new 
IllegalStateException("boom"));
+
+    runDeletion(instanceDataManager, mock(ServerMetrics.class));
+
+    verify(instanceDataManager, times(1)).deleteTable(eq(TABLE), anyLong());
+  }
+
+  @Test
+  public void testDeletionProceedsWithoutATableDataManager()
+      throws Exception {
+    InstanceDataManager instanceDataManager = mock(InstanceDataManager.class);
+    when(instanceDataManager.getTableDataManager(TABLE)).thenReturn(null);
+
+    runDeletion(instanceDataManager, mock(ServerMetrics.class));
+
+    verify(instanceDataManager, times(1)).deleteTable(eq(TABLE), anyLong());
+  }
+}
diff --git 
a/pinot-spi/src/main/java/org/apache/pinot/spi/config/table/OpenStructIndexConfig.java
 
b/pinot-spi/src/main/java/org/apache/pinot/spi/config/table/OpenStructIndexConfig.java
index ad141bc7276..0c09fde132c 100644
--- 
a/pinot-spi/src/main/java/org/apache/pinot/spi/config/table/OpenStructIndexConfig.java
+++ 
b/pinot-spi/src/main/java/org/apache/pinot/spi/config/table/OpenStructIndexConfig.java
@@ -62,6 +62,7 @@ public class OpenStructIndexConfig extends IndexConfig {
   private final double _denseKeyMinFillRate;
   private final List<FieldConfig> _valueFieldConfigs;
   private final boolean _sparseJsonIndex;
+  private final boolean _perKeyMetricsEnabled;
   // Eager lookup from key name → FieldConfig for O(1) per-key access. Built 
in constructor
   // so the config is fully immutable and safe to share across threads.
   private final Map<String, FieldConfig> _valueFieldConfigIndex;
@@ -79,6 +80,16 @@ public class OpenStructIndexConfig extends IndexConfig {
     this(disabled, defaultValueFieldConfig, maxDenseKeys, denseKeys, 
denseKeyMinFillRate, valueFieldConfigs, null);
   }
 
+  /// @deprecated Use the 8-arg constructor accepting `perKeyMetricsEnabled`. 
Kept for binary
+  /// compatibility with existing callers built against the 
pre-`perKeyMetricsEnabled` signature.
+  @Deprecated
+  public OpenStructIndexConfig(Boolean disabled, @Nullable FieldConfig 
defaultValueFieldConfig,
+      @Nullable Integer maxDenseKeys, @Nullable Set<String> denseKeys, 
@Nullable Double denseKeyMinFillRate,
+      @Nullable List<FieldConfig> valueFieldConfigs, @Nullable Boolean 
sparseJsonIndex) {
+    this(disabled, defaultValueFieldConfig, maxDenseKeys, denseKeys, 
denseKeyMinFillRate, valueFieldConfigs,
+        sparseJsonIndex, null);
+  }
+
   @JsonCreator
   public OpenStructIndexConfig(
       @JsonProperty("disabled") Boolean disabled,
@@ -87,7 +98,8 @@ public class OpenStructIndexConfig extends IndexConfig {
       @JsonProperty("denseKeys") @Nullable Set<String> denseKeys,
       @JsonProperty("denseKeyMinFillRate") @Nullable Double 
denseKeyMinFillRate,
       @JsonProperty("valueFieldConfigs") @Nullable List<FieldConfig> 
valueFieldConfigs,
-      @JsonProperty("sparseJsonIndex") @Nullable Boolean sparseJsonIndex) {
+      @JsonProperty("sparseJsonIndex") @Nullable Boolean sparseJsonIndex,
+      @JsonProperty("perKeyMetricsEnabled") @Nullable Boolean 
perKeyMetricsEnabled) {
     super(disabled);
     _defaultValueFieldConfig = defaultValueFieldConfig;
     _maxDenseKeys = maxDenseKeys != null ? maxDenseKeys : 
DEFAULT_MAX_DENSE_KEYS;
@@ -95,6 +107,7 @@ public class OpenStructIndexConfig extends IndexConfig {
     _denseKeyMinFillRate = denseKeyMinFillRate != null ? denseKeyMinFillRate : 
DEFAULT_DENSE_KEY_MIN_FILL_RATE;
     _valueFieldConfigs = valueFieldConfigs;
     _sparseJsonIndex = sparseJsonIndex != null && sparseJsonIndex;
+    _perKeyMetricsEnabled = perKeyMetricsEnabled != null && 
perKeyMetricsEnabled;
     if (valueFieldConfigs == null || valueFieldConfigs.isEmpty()) {
       _valueFieldConfigIndex = Map.of();
     } else {
@@ -181,6 +194,15 @@ public class OpenStructIndexConfig extends IndexConfig {
     return _sparseJsonIndex;
   }
 
+  /// When `true`, `OPEN_STRUCT_LAST_SEGMENT_KEY_DOC_COUNT` is emitted for 
every key present in the
+  /// sealed segment — dense, sparse, configured, or discovered. The cost is 
that the number of
+  /// metrics-registry entries follows the ingested key space, and table 
deletion can only sweep keys
+  /// recoverable from `denseKeys`. When `false` (default), the gauge fires 
only for keys named in
+  /// `denseKeys`.
+  public boolean isPerKeyMetricsEnabled() {
+    return _perKeyMetricsEnabled;
+  }
+
   private static boolean invertedFromIndexes(FieldConfig fieldConfig, String 
key) {
     JsonNode indexes = fieldConfig.getIndexes();
     if (indexes == null || !indexes.isObject()) {
diff --git 
a/pinot-spi/src/main/java/org/apache/pinot/spi/data/OpenStructNaming.java 
b/pinot-spi/src/main/java/org/apache/pinot/spi/data/OpenStructNaming.java
index a0061c6d1a4..a64b25d0443 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/data/OpenStructNaming.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/data/OpenStructNaming.java
@@ -22,6 +22,9 @@ package org.apache.pinot.spi.data;
 /// Naming convention for OPEN_STRUCT materialized columns. Each dense 
OPEN_STRUCT key is stored as
 /// a column named `<openStructColumn>$<key>`. Sparse keys share a single 
synthetic JSON column
 /// named `<openStructColumn>$__sparse__`.
+///
+/// [#metricKey] builds a superficially similar `<openStructColumn>$<key>` 
string for per-key metric
+/// names, but escapes the key for JMX export and is not a column name -- see 
its docs.
 public final class OpenStructNaming {
   public static final String SEPARATOR = "$";
   public static final String SPARSE_SUFFIX = "__sparse__";
@@ -33,6 +36,23 @@ public final class OpenStructNaming {
     return openStructColumn + SEPARATOR + key;
   }
 
+  /// Builds the `<openStructColumn>$<key>` identifier used as the key segment 
of a per-key OPEN_STRUCT
+  /// metric name; the scrape rule splits it back into `column` and `key` 
labels. Not a column name --
+  /// the key need not have one on disk, and the escaped output must not be 
fed to [#parseKey] or
+  /// [#parseParentColumn].
+  ///
+  /// Percent-escapes the four characters `javax.management.ObjectName.quote` 
backslash-escapes on the
+  /// way to JMX (`"`, `\`, `*`, `?`), plus `%` itself so the mapping stays 
reversible. An escaped `"`
+  /// stops the name matching the per-key rule in
+  /// `docker/images/pinot/etc/jmx_prometheus_javaagent/configs/server.yml` at 
all; the other three
+  /// leave a stray backslash in the label value. Nothing else is touched, so 
`user.id`, `user-id` and
+  /// `user_id` stay distinct series.
+  public static String metricKey(String openStructColumn, String key) {
+    // '%' first so the escapes introduced below are not themselves re-escaped.
+    return openStructColumn + SEPARATOR + key.replace("%", 
"%25").replace("\"", "%22").replace("\\", "%5C")
+        .replace("*", "%2A").replace("?", "%3F");
+  }
+
   public static String sparseColumnName(String openStructColumn) {
     return openStructColumn + SEPARATOR + SPARSE_SUFFIX;
   }
diff --git 
a/pinot-spi/src/test/java/org/apache/pinot/spi/config/table/OpenStructIndexConfigTest.java
 
b/pinot-spi/src/test/java/org/apache/pinot/spi/config/table/OpenStructIndexConfigTest.java
index 48e58935fc0..d4f26c23702 100644
--- 
a/pinot-spi/src/test/java/org/apache/pinot/spi/config/table/OpenStructIndexConfigTest.java
+++ 
b/pinot-spi/src/test/java/org/apache/pinot/spi/config/table/OpenStructIndexConfigTest.java
@@ -46,6 +46,28 @@ public class OpenStructIndexConfigTest {
     assertTrue(config.shouldUseDictionaryForKey("any"));
   }
 
+  @Test
+  public void testPerKeyMetricsDefaultsToFalse() {
+    // 7-arg constructor (pre-existing callers) — must default to false.
+    OpenStructIndexConfig config = new OpenStructIndexConfig(false, null, -1, 
null, 0.5, null, null);
+    assertFalse(config.isPerKeyMetricsEnabled());
+    // DEFAULT constant.
+    assertFalse(OpenStructIndexConfig.DEFAULT.isPerKeyMetricsEnabled());
+  }
+
+  @Test
+  public void testPerKeyMetricsRoundTripsFromJson()
+      throws Exception {
+    String json = "{\"perKeyMetricsEnabled\": true, \"denseKeys\": 
[\"clicks\"]}";
+    OpenStructIndexConfig config = JsonUtils.stringToObject(json, 
OpenStructIndexConfig.class);
+    assertTrue(config.isPerKeyMetricsEnabled());
+    assertEquals(config.getDenseKeys(), Set.of("clicks"));
+
+    // Absent field → false.
+    OpenStructIndexConfig noFlag = JsonUtils.stringToObject("{}", 
OpenStructIndexConfig.class);
+    assertFalse(noFlag.isPerKeyMetricsEnabled());
+  }
+
   @Test
   public void testDisabledConfig() {
     OpenStructIndexConfig config = OpenStructIndexConfig.DISABLED;
diff --git 
a/pinot-spi/src/test/java/org/apache/pinot/spi/data/OpenStructNamingTest.java 
b/pinot-spi/src/test/java/org/apache/pinot/spi/data/OpenStructNamingTest.java
index 5c057d4acac..db7c5c8e38b 100644
--- 
a/pinot-spi/src/test/java/org/apache/pinot/spi/data/OpenStructNamingTest.java
+++ 
b/pinot-spi/src/test/java/org/apache/pinot/spi/data/OpenStructNamingTest.java
@@ -18,10 +18,14 @@
  */
 package org.apache.pinot.spi.data;
 
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
 import org.testng.annotations.Test;
 
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotEquals;
 import static org.testng.Assert.assertTrue;
 
 
@@ -72,4 +76,64 @@ public class OpenStructNamingTest {
   public void testParseKeyRejectsNonMaterialized() {
     OpenStructNaming.parseKey("metrics");
   }
+
+  /// Only the four characters ObjectName.quote backslash-escapes are escaped, 
plus '%' itself so the
+  /// mapping stays reversible. Everything else has to survive, or keys that 
differ only in punctuation
+  /// would collapse into one metric series.
+  @Test
+  public void testMetricKeyEscapesOnlyObjectNameEscapedChars() {
+    assertEquals(OpenStructNaming.metricKey("metrics", "pro\"mo"), 
"metrics$pro%22mo");
+    assertEquals(OpenStructNaming.metricKey("metrics", "pro\\mo"), 
"metrics$pro%5Cmo");
+    assertEquals(OpenStructNaming.metricKey("metrics", "pro*mo"), 
"metrics$pro%2Amo");
+    assertEquals(OpenStructNaming.metricKey("metrics", "pro?mo"), 
"metrics$pro%3Fmo");
+    assertEquals(OpenStructNaming.metricKey("metrics", "pro%mo"), 
"metrics$pro%25mo");
+
+    // Untouched: these are all safe inside a quoted ObjectName and in a 
Prometheus label value.
+    assertEquals(OpenStructNaming.metricKey("metrics", 
"clicks.v2$promo-code"), "metrics$clicks.v2$promo-code");
+    assertEquals(OpenStructNaming.metricKey("metrics", "a,b"), "metrics$a,b");
+    assertEquals(OpenStructNaming.metricKey("metrics", "a=b"), "metrics$a=b");
+    assertEquals(OpenStructNaming.metricKey("metrics", "a b"), "metrics$a b");
+    assertEquals(OpenStructNaming.metricKey("metrics", "user_id"), 
"metrics$user_id");
+  }
+
+  /// The escaping must be injective. Folding the four to '_' would not be: 
'_' is itself a legal key
+  /// character, so 'a"b' and 'a_b' would land on the same gauge and each seal 
would silently overwrite
+  /// the other. '%' is escaped for the same reason -- without it 'a%22b' 
would collide with 'a"b'.
+  @Test
+  public void testMetricKeyEscapingIsInjective() {
+    assertNotEquals(OpenStructNaming.metricKey("metrics", "a\"b"), 
OpenStructNaming.metricKey("metrics", "a_b"));
+    assertNotEquals(OpenStructNaming.metricKey("metrics", "a\"b"), 
OpenStructNaming.metricKey("metrics", "a%22b"));
+    assertNotEquals(OpenStructNaming.metricKey("metrics", "a\\b"), 
OpenStructNaming.metricKey("metrics", "a%5Cb"));
+
+    // The four escaped characters must not collide with each other either.
+    Set<String> encoded = new HashSet<>();
+    for (String key : List.of("a\"b", "a\\b", "a*b", "a?b", "a%b", "a_b")) {
+      assertTrue(encoded.add(OpenStructNaming.metricKey("metrics", key)), 
"collision on key: " + key);
+    }
+  }
+
+  /// user.id / user-id / user_id must stay distinct series -- the reason 
escaping is narrow.
+  @Test
+  public void testMetricKeyKeepsPunctuationVariantsDistinct() {
+    String a = OpenStructNaming.metricKey("metrics", "user.id");
+    String b = OpenStructNaming.metricKey("metrics", "user-id");
+    String c = OpenStructNaming.metricKey("metrics", "user_id");
+    assertNotEquals(a, b);
+    assertNotEquals(b, c);
+    assertNotEquals(a, c);
+  }
+
+  /// A key needing no escaping must produce exactly the materialized column 
name, so the dense-key
+  /// metric and its on-disk column stay addressable by the same string in the 
common case.
+  @Test
+  public void testMetricKeyMatchesMaterializedNameWhenNoEscapingNeeded() {
+    assertEquals(OpenStructNaming.metricKey("metrics", "clicks"),
+        OpenStructNaming.materializedColumnName("metrics", "clicks"));
+  }
+
+  @Test
+  public void testMetricKeyHandlesEmptyAndAllEscapedKeys() {
+    assertEquals(OpenStructNaming.metricKey("metrics", ""), "metrics$");
+    assertEquals(OpenStructNaming.metricKey("metrics", "\"\\*?%"), 
"metrics$%22%5C%2A%3F%25");
+  }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to