package com.example.vmp;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.Credentials;
import okhttp3.Interceptor;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.RequestBody;
import okhttp3.Response;
import org.xerial.snappy.Snappy;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Locale;
import java.util.TreeMap;
public class VmpWorkspaceClient {
private static final MediaType PROTOBUF = MediaType.parse("application/x-protobuf");
private static final ObjectMapper MAPPER = new ObjectMapper();
private final WorkspaceConfig config;
private final OkHttpClient httpClient;
public VmpWorkspaceClient(WorkspaceConfig config) {
OkHttpClient.Builder builder = new OkHttpClient.Builder()
.callTimeout(Duration.ofSeconds(config.timeoutSeconds));
if ("basic".equalsIgnoreCase(config.authMode)) {
builder.addInterceptor(new BasicAuthInterceptor(config.username, config.password));
} else if ("aksk".equalsIgnoreCase(config.authMode)) {
builder.addInterceptor(new VolcSignatureInterceptor(
config.accessKey, config.secretKey, config.region, config.service, config.sessionToken));
throw new IllegalArgumentException("Unsupported auth mode: " + config.authMode);
this.httpClient = builder.build();
public JsonNode query(String promql) throws IOException {
return query(promql, null);
public JsonNode query(String promql, Instant ts) throws IOException {
HttpUrl.Builder urlBuilder = HttpUrl.parse(config.queryUrl + "/api/v1/query").newBuilder()
.addQueryParameter("query", promql);
urlBuilder.addQueryParameter("time", toRfc3339(ts));
return executeGet(urlBuilder.build());
public JsonNode queryRange(String promql, Instant start, Instant end, String step) throws IOException {
HttpUrl url = HttpUrl.parse(config.queryUrl + "/api/v1/query_range").newBuilder()
.addQueryParameter("query", promql)
.addQueryParameter("start", toRfc3339(start))
.addQueryParameter("end", toRfc3339(end))
.addQueryParameter("step", step)
public JsonNode series(List<String> matches, Instant start, Instant end) throws IOException {
if (matches == null || matches.isEmpty()) {
throw new IllegalArgumentException("series API requires at least one match selector");
HttpUrl.Builder builder = HttpUrl.parse(config.queryUrl + "/api/v1/series").newBuilder();
for (String match : matches) {
builder.addQueryParameter("match[]", match);
if (start != null) builder.addQueryParameter("start", toRfc3339(start));
if (end != null) builder.addQueryParameter("end", toRfc3339(end));
return executeGet(builder.build());
public JsonNode labels(Instant start, Instant end, List<String> matches) throws IOException {
HttpUrl.Builder builder = HttpUrl.parse(config.queryUrl + "/api/v1/labels").newBuilder();
for (String match : matches) builder.addQueryParameter("match[]", match);
if (start != null) builder.addQueryParameter("start", toRfc3339(start));
if (end != null) builder.addQueryParameter("end", toRfc3339(end));
return executeGet(builder.build());
public JsonNode labelValues(String labelName, Instant start, Instant end, List<String> matches) throws IOException {
if (labelName == null || labelName.isBlank()) {
throw new IllegalArgumentException("labelValues API requires a non-empty labelName");
HttpUrl.Builder builder = HttpUrl.parse(config.queryUrl + "/api/v1/label/" + labelName + "/values").newBuilder();
for (String match : matches) builder.addQueryParameter("match[]", match);
if (start != null) builder.addQueryParameter("start", toRfc3339(start));
if (end != null) builder.addQueryParameter("end", toRfc3339(end));
return executeGet(builder.build());
public String writeSamples(List<SampleEntry> series) throws IOException {
if (config.remoteWriteUrl == null) {
throw new IllegalStateException("remote_write_url is not configured");
byte[] payload = encodeWriteRequest(series);
byte[] compressed = Snappy.compress(payload);
Request request = new Request.Builder()
.url(config.remoteWriteUrl)
.post(RequestBody.create(compressed, PROTOBUF))
.header("Content-Type", "application/x-protobuf")
.header("Content-Encoding", "snappy")
.header("X-Prometheus-Remote-Write-Version", "0.1.0")
try (Response response = httpClient.newCall(request).execute()) {
String body = response.body() == null ? "" : response.body().string();
if (!response.isSuccessful()) {
throw new IOException("Remote Write failed: HTTP " + response.code() + ", response: " + body);
private void requireQueryUrl() {
if (config.queryUrl == null) {
throw new IllegalStateException("query_url is not configured");
private JsonNode executeGet(HttpUrl url) throws IOException {
Request request = new Request.Builder().url(url).get().build();
try (Response response = httpClient.newCall(request).execute()) {
String body = response.body() == null ? "" : response.body().string();
if (!response.isSuccessful()) {
throw new IOException("GET " + url.encodedPath() + " failed: HTTP " + response.code() + ", response: " + body);
return MAPPER.readTree(body);
private static String toRfc3339(Instant instant) {
return RFC3339.format(instant);
private static byte[] encodeWriteRequest(List<SampleEntry> series) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
for (SampleEntry entry : series) {
byte[] tsBytes = encodeTimeSeries(entry);
writeVarint(out, tsBytes.length);
return out.toByteArray();
private static byte[] encodeTimeSeries(SampleEntry entry) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
List<Map.Entry<String, String>> sorted = new ArrayList<>(entry.labels.entrySet());
sorted.sort(Map.Entry.comparingByKey());
for (Map.Entry<String, String> label : sorted) {
byte[] labelBytes = encodeLabel(label.getKey(), label.getValue());
writeVarint(out, labelBytes.length);
byte[] sampleBytes = encodeSample(entry.value, entry.timestampMs);
writeVarint(out, sampleBytes.length);
return out.toByteArray();
private static byte[] encodeLabel(String name, String value) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] nameBytes = name.getBytes(StandardCharsets.UTF_8);
writeVarint(out, nameBytes.length);
byte[] valueBytes = value.getBytes(StandardCharsets.UTF_8);
writeVarint(out, valueBytes.length);
return out.toByteArray();
private static byte[] encodeSample(double value, long timestampMs) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
if (Double.doubleToRawLongBits(value) != 0) {
writeLittleEndian64(out, Double.doubleToRawLongBits(value));
writeVarint(out, timestampMs);
return out.toByteArray();
private static void writeTag(ByteArrayOutputStream out, int fieldNumber, int wireType) {
writeVarint(out, (fieldNumber << 3) | wireType);
private static void writeVarint(ByteArrayOutputStream out, long value) {
while ((value & ~0x7FL) != 0) {
out.write((int) ((value & 0x7F) | 0x80));
private static void writeLittleEndian64(ByteArrayOutputStream out, long value) {
for (int i = 0; i < 8; i++) {
out.write((int) (value & 0xFF));
public static class SampleEntry {
public final Map<String, String> labels;
public final double value;
public final long timestampMs;
public SampleEntry(Map<String, String> labels, double value, long timestampMs) {
this.timestampMs = timestampMs;
public static class WorkspaceConfig {
public final String queryUrl;
public final String remoteWriteUrl;
public final String authMode;
public final String region;
public final String service;
public final String accessKey;
public final String secretKey;
public final String sessionToken;
public final String username;
public final String password;
public final long timeoutSeconds;
this.queryUrl = queryUrl == null || queryUrl.isBlank() ? null : trimTrailingSlash(queryUrl);
this.remoteWriteUrl = remoteWriteUrl == null || remoteWriteUrl.isBlank() ? null : remoteWriteUrl;
this.authMode = authMode == null ? "aksk" : authMode.toLowerCase(Locale.ROOT);
this.service = service == null || service.isBlank() ? "vmp" : service;
this.accessKey = accessKey;
this.secretKey = secretKey;
this.sessionToken = sessionToken == null ? "" : sessionToken;
this.username = username;
this.password = password;
this.timeoutSeconds = timeoutSeconds <= 0 ? 30 : timeoutSeconds;
if ("basic".equals(this.authMode)) {
required(username, "username");
required(password, "password");
} else if ("aksk".equals(this.authMode)) {
required(region, "region");
required(accessKey, "access_key");
required(secretKey, "secret_key");
public static WorkspaceConfig fromEnv() {
return new WorkspaceConfig(
env("VMP_REMOTE_WRITE_URL"),
envOr("VMP_AUTH_MODE", "aksk"),
firstNonBlank(env("VOLCENGINE_REGION"), env("VMP_REGION")),
envOr("VMP_SERVICE", "vmp"),
firstNonBlank(env("VOLCENGINE_ACCESS_KEY"), env("VMP_ACCESS_KEY")),
firstNonBlank(env("VOLCENGINE_SECRET_KEY"), env("VMP_SECRET_KEY")),
envOr("VOLCENGINE_SESSION_TOKEN", ""),
env("VMP_BASIC_AUTH_USERNAME"),
env("VMP_BASIC_AUTH_PASSWORD"),
Long.parseLong(envOr("VMP_TIMEOUT_SECONDS", "30"))
private static String env(String name) {
return System.getenv(name);
private static String envOr(String name, String defaultValue) {
String value = System.getenv(name);
return (value != null && !value.isBlank()) ? value : defaultValue;
private static String firstNonBlank(String... values) {
for (String v : values) {
if (v != null && !v.isBlank()) return v;
private static String required(String value, String name) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Missing required config: " + name);
private static String trimTrailingSlash(String value) {
while (value.endsWith("/")) value = value.substring(0, value.length() - 1);
static class BasicAuthInterceptor implements Interceptor {
private final String authorization;
BasicAuthInterceptor(String username, String password) {
this.authorization = Credentials.basic(username, password, StandardCharsets.UTF_8);
public Response intercept(Chain chain) throws IOException {
return chain.proceed(chain.request().newBuilder()
.header("Authorization", authorization)
static class VolcSignatureInterceptor implements Interceptor {
private final String accessKey;
private final String secretKey;
private final String region;
private final String service;
private final String sessionToken;
VolcSignatureInterceptor(String accessKey, String secretKey, String region, String service, String sessionToken) {
this.accessKey = accessKey;
this.secretKey = secretKey;
this.sessionToken = sessionToken == null ? "" : sessionToken;
public Response intercept(Chain chain) throws IOException {
Request request = chain.request();
byte[] body = readBody(request);
HttpUrl url = request.url();
String xDate = X_DATE.format(Instant.now());
String shortDate = xDate.substring(0, 8);
String payloadHash = sha256Hex(body);
Map<String, String> signedHeaders = collectHeaders(request, url, xDate, payloadHash, sessionToken);
String canonicalHeaders = canonicalHeaders(signedHeaders);
String signedHeaderNames = String.join(";", signedHeaders.keySet());
String canonicalRequest = request.method() + "\n"
+ canonicalUri(url.encodedPath()) + "\n"
+ canonicalQuery(url) + "\n"
+ canonicalHeaders + "\n"
+ signedHeaderNames + "\n"
String credentialScope = shortDate + "/" + region + "/" + service + "/request";
String stringToSign = "HMAC-SHA256\n" + xDate + "\n" + credentialScope + "\n"
+ sha256Hex(canonicalRequest.getBytes(StandardCharsets.UTF_8));
byte[] signingKey = hmac(hmac(hmac(hmac(
secretKey.getBytes(StandardCharsets.UTF_8), shortDate), region), service), "request");
String signature = toHex(hmac(signingKey, stringToSign));
String authorization = "HMAC-SHA256 Credential=" + accessKey + "/" + credentialScope
+ ", SignedHeaders=" + signedHeaderNames
+ ", Signature=" + signature;
Request.Builder builder = request.newBuilder()
.header("Host", hostHeader(url))
.header("X-Date", xDate)
.header("X-Content-Sha256", payloadHash)
.header("Authorization", authorization);
if (!sessionToken.isBlank()) {
builder.header("X-Security-Token", sessionToken);
return chain.proceed(builder.build());
private static Map<String, String> collectHeaders(
Request request, HttpUrl url, String xDate, String payloadHash, String sessionToken) {
TreeMap<String, String> headers = new TreeMap<>();
headers.put("host", hostHeader(url));
headers.put("x-date", xDate);
headers.put("x-content-sha256", payloadHash);
if (sessionToken != null && !sessionToken.isBlank()) {
headers.put("x-security-token", sessionToken);
if (request.header("Content-Type") != null) {
headers.put("content-type", request.header("Content-Type"));
for (String name : request.headers().names()) {
String lower = name.toLowerCase(Locale.ROOT);
if (lower.startsWith("x-") || lower.equals("content-type") || lower.equals("host")) {
headers.put(lower, request.header(name));
private static final DateTimeFormatter X_DATE = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC);
private static final DateTimeFormatter RFC3339 = DateTimeFormatter.ISO_OFFSET_DATE_TIME.withZone(ZoneOffset.UTC);
private static String canonicalHeaders(Map<String, String> headers) {
StringBuilder sb = new StringBuilder();
headers.forEach((k, v) -> sb.append(k).append(':').append(v.trim()).append('\n'));
private static String canonicalUri(String path) {
if (path == null || path.isEmpty()) return "/";
return encode(path).replace("%2F", "/");
private static String canonicalQuery(HttpUrl url) {
List<String> entries = new ArrayList<>();
for (int i = 0; i < url.querySize(); i++) {
String name = url.queryParameterName(i);
String value = url.queryParameterValue(i);
entries.add(encode(name) + "=" + encode(value == null ? "" : value));
entries.sort(Comparator.naturalOrder());
return String.join("&", entries);
private static byte[] readBody(Request request) throws IOException {
if (request.body() == null) return new byte[0];
okio.Buffer buffer = new okio.Buffer();
request.body().writeTo(buffer);
return buffer.readByteArray();
private static String hostHeader(HttpUrl url) {
boolean std = (url.isHttps() && port == 443) || (!url.isHttps() && port == 80);
return std ? url.host() : url.host() + ":" + port;
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8)
.replace("+", "%20").replace("*", "%2A").replace("%7E", "~");
private static String sha256Hex(byte[] data) {
return toHex(MessageDigest.getInstance("SHA-256").digest(data));
} catch (Exception e) { throw new RuntimeException(e); }
private static byte[] hmac(byte[] key, String data) {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) { throw new RuntimeException(e); }
private static String toHex(byte[] data) {
StringBuilder sb = new StringBuilder();
for (byte b : data) sb.append(String.format("%02x", b));