ServerTiming.java
// SPDX-License-Identifier: Apache-2.0
package com.shengzhiai.yugu.stcompat.internal;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
/**
* Server side timing of one compat evaluation (platform change of 2026-10-13, SDK 2.1.1):
*
* <ul>
* <li>every successful compat REST answer carries
* {@code Server-Timing: total;dur=<ms>, engine;dur=<ms>, platform;dur=<ms>}</li>
* <li>with the form field {@code timing=1} (REST) or {@code "timing": 1} in the parameter frame
* (WebSocket) the result gains the top-level object
* {@code "timing":{"receivedAt","totalMs","engineMs","platformMs","engineStages":{...},"audioBytes","wsFirstAudioAt"}};
* engineStages and wsFirstAudioAt may be absent, unknown keys are ignored</li>
* </ul>
*
* The SDK asks for the object only while its log level is INFO or DEBUG ({@link #wanted()}) and
* always removes it ({@link #split}) before the result reaches the app, so apps see the result
* JSON of 2.1.0.
*/
public final class ServerTiming {
/** Form field, parameter frame key and result member. */
public static final String KEY = "timing";
/** Platform processing from the received request (REST) or end frame (WebSocket) to the result, -1 when unknown. */
public long totalMs = -1;
/** The call to the evaluation engine, -1 when unknown. */
public long engineMs = -1;
/** total minus engine, -1 when unknown. */
public long platformMs = -1;
/** Platform epoch ms when processing started, -1 when unknown. */
public long receivedAt = -1;
/** Audio bytes the platform received, -1 when unknown. */
public long audioBytes = -1;
/** Platform epoch ms of the first audio frame (WebSocket only), -1 when unknown. */
public long wsFirstAudioAt = -1;
/** engineStages as sent: stage name to the raw number text, in platform order. */
public final LinkedHashMap<String, String> engineStages = new LinkedHashMap<String, String>();
/** A platform body without its timing member, and that member parsed (null when absent or unreadable). */
public static final class Split {
public final String body;
public final ServerTiming timing;
Split(String body, ServerTiming timing) {
this.body = body;
this.timing = timing;
}
}
/** True while the SDK log level is INFO or DEBUG: only then requests carry {@code timing=1}. */
public static boolean wanted() {
return YLog.isLoggable(YLog.INFO);
}
/** {@code fields}, or a copy with {@code timing=1} added when {@code timing} is true. */
public static LinkedHashMap<String, String> requestFields(LinkedHashMap<String, String> fields, boolean timing) {
if (!timing) {
return fields;
}
LinkedHashMap<String, String> f = new LinkedHashMap<String, String>(fields);
f.put(KEY, "1");
return f;
}
/**
* Removes the top-level {@code timing} member of a platform body. Everything else stays byte for
* byte; a body that is not a JSON object is returned unchanged for the caller to reject.
*/
public static Split split(String platformBody) {
Map<String, String> m;
try {
m = RawJson.members(platformBody);
} catch (IllegalArgumentException e) {
return new Split(platformBody, null);
}
String raw = m.get(KEY);
if (raw == null) {
return new Split(platformBody, null);
}
return new Split(RawJson.removeMember(platformBody, KEY), fromObject(raw));
}
/** Parses the raw text of a timing object; null when it is not a JSON object. */
public static ServerTiming fromObject(String raw) {
String text = raw == null ? "" : raw.trim();
Map<String, String> m;
try {
m = RawJson.members(text);
} catch (IllegalArgumentException e) {
return null;
}
ServerTiming t = new ServerTiming();
t.totalMs = number(m.get("totalMs"));
t.engineMs = number(m.get("engineMs"));
t.platformMs = number(m.get("platformMs"));
t.receivedAt = number(m.get("receivedAt"));
t.audioBytes = number(m.get("audioBytes"));
t.wsFirstAudioAt = number(m.get("wsFirstAudioAt"));
String stages = m.get("engineStages");
if (stages != null && stages.trim().startsWith("{")) {
try {
for (Map.Entry<String, String> e : RawJson.members(stages.trim()).entrySet()) {
String v = e.getValue().trim();
if (!v.startsWith("{") && !v.startsWith("[")) {
t.engineStages.put(e.getKey(), v);
}
}
} catch (IllegalArgumentException ignored) {
// stages are informational
}
}
if (t.platformMs < 0 && t.totalMs >= 0 && t.engineMs >= 0) {
t.platformMs = Math.max(0, t.totalMs - t.engineMs);
}
return t;
}
/**
* Parses a Server-Timing header value ({@code name;dur=1.5;desc="x", ...}); null when the value
* is absent or names none of total, engine and platform.
*/
public static ServerTiming fromHeader(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
ServerTiming t = new ServerTiming();
boolean any = false;
for (String metric : split(value, ',')) {
java.util.List<String> parts = split(metric, ';');
if (parts.isEmpty()) {
continue;
}
String name = parts.get(0).trim().toLowerCase(Locale.ROOT);
long dur = -1;
for (int i = 1; i < parts.size(); i++) {
String p = parts.get(i).trim();
int eq = p.indexOf('=');
if (eq > 0 && "dur".equalsIgnoreCase(p.substring(0, eq).trim())) {
dur = number(unquote(p.substring(eq + 1).trim()));
}
}
if ("total".equals(name)) {
t.totalMs = dur;
any = true;
} else if ("engine".equals(name)) {
t.engineMs = dur;
any = true;
} else if ("platform".equals(name)) {
t.platformMs = dur;
any = true;
}
}
return any ? t : null;
}
/** {@code server.total=..ms server.engine=..ms server.platform=..ms}, unknown values left out. */
public String describe() {
StringBuilder sb = new StringBuilder();
append(sb, "server.total", totalMs);
append(sb, "server.engine", engineMs);
append(sb, "server.platform", platformMs);
return sb.toString().trim();
}
/** {@code name=value} pairs of engineStages in platform order, "" when none. */
public String describeStages() {
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> e : engineStages.entrySet()) {
if (sb.length() > 0) {
sb.append(' ');
}
sb.append(e.getKey()).append('=').append(e.getValue());
}
return sb.toString();
}
private static void append(StringBuilder sb, String name, long ms) {
if (ms >= 0) {
sb.append(' ').append(name).append('=').append(ms).append("ms");
}
}
/** Rounded value of a raw JSON number or numeric string, -1 when absent or not a number. */
static long number(String raw) {
if (raw == null) {
return -1;
}
String t = RawJson.isString(raw) ? RawJson.unquote(raw) : raw.trim();
try {
double d = Double.parseDouble(t);
return Double.isNaN(d) || Double.isInfinite(d) ? -1 : Math.round(d);
} catch (NumberFormatException e) {
return -1;
}
}
private static String unquote(String v) {
return v.length() >= 2 && v.charAt(0) == '"' && v.charAt(v.length() - 1) == '"' ? v.substring(1, v.length() - 1) : v;
}
/** Pieces of {@code s} split at {@code sep} outside double quotes. */
private static java.util.List<String> split(String s, char sep) {
java.util.List<String> out = new java.util.ArrayList<String>();
StringBuilder cur = new StringBuilder();
boolean quoted = false;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (quoted && c == '\\' && i + 1 < s.length()) {
cur.append(c).append(s.charAt(++i));
continue;
}
if (c == '"') {
quoted = !quoted;
}
if (c == sep && !quoted) {
out.add(cur.toString());
cur.setLength(0);
} else {
cur.append(c);
}
}
out.add(cur.toString());
return out;
}
}