prometheus support

This commit is contained in:
Emil Lerch 2026-01-07 08:27:08 -08:00
parent add82dee40
commit b46940a7a3
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 225 additions and 10 deletions

View file

@ -2,24 +2,20 @@
Features not yet implemented in the Zig version:
## 1. Prometheus Metrics Format (format=p1)
- Export weather data in Prometheus metrics format
- See API_ENDPOINTS.md for format specification
## 2. PNG Generation
## 1. PNG Generation
- Render weather reports as PNG images
- Support transparency and custom styling
- Requires image rendering library integration
## 3. Language/Localization
## 2. Language/Localization
- Accept-Language header parsing
- lang query parameter support
- Translation of weather conditions and text (54 languages)
## 4. Json output
## 3. Json output
- Does not match wttr.in format
## 5. Moon endpoint
## 4. Moon endpoint
- `/Moon` and `/Moon@YYYY-MM-DD` endpoints not yet implemented
- Moon phase calculation is implemented and available in custom format (%m, %M)

View file

@ -178,8 +178,6 @@ The result will look like:
## Prometheus Metrics Output
**Note:** Not yet implemented - see [MISSING_FEATURES.md](MISSING_FEATURES.md)
To fetch information in Prometheus format:
```bash

View file

@ -28,6 +28,28 @@ pub const Phase = struct {
pub fn day(self: Phase) u8 {
return @intFromFloat(@round(self.age_days));
}
pub fn format(self: Phase, writer: *std.Io.Writer) std.Io.Writer.Error!void {
const name = if (self.phase < 0.0625)
"New Moon"
else if (self.phase < 0.1875)
"Waxing Crescent"
else if (self.phase < 0.3125)
"First Quarter"
else if (self.phase < 0.4375)
"Waxing Gibbous"
else if (self.phase < 0.5625)
"Full Moon"
else if (self.phase < 0.6875)
"Waning Gibbous"
else if (self.phase < 0.8125)
"Last Quarter"
else if (self.phase < 0.9375)
"Waning Crescent"
else
"New Moon";
try writer.print("{s}", .{name});
}
};
pub fn getPhase(timestamp: i64) Phase {

View file

@ -8,6 +8,7 @@ const Line = @import("../render/Line.zig");
const Json = @import("../render/Json.zig");
const V2 = @import("../render/V2.zig");
const Custom = @import("../render/Custom.zig");
const Prometheus = @import("../render/Prometheus.zig");
const help = @import("help.zig");
const log = std.log.scoped(.handler);
@ -147,6 +148,8 @@ fn handleWeatherInternal(
res.content_type = .JSON; // reset to json
break :blk try Json.render(req_alloc, weather);
}
if (std.mem.eql(u8, fmt, "p1"))
break :blk try Prometheus.render(req_alloc, weather);
if (std.mem.eql(u8, fmt, "v2"))
break :blk try V2.render(req_alloc, weather, render_options.use_imperial);
if (std.mem.startsWith(u8, fmt, "%"))

196
src/render/Prometheus.zig Normal file
View file

@ -0,0 +1,196 @@
const std = @import("std");
const types = @import("../weather/types.zig");
const Moon = @import("../Moon.zig");
pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData) ![]const u8 {
var output: std.ArrayList(u8) = .empty;
errdefer output.deinit(allocator);
const writer = output.writer(allocator);
// Current conditions
try writer.print("# HELP temperature_feels_like_celsius Feels Like Temperature in Celsius\n", .{});
try writer.print("temperature_feels_like_celsius{{forecast=\"current\"}} {d}\n", .{weather.current.feels_like_c});
try writer.print("# HELP temperature_feels_like_fahrenheit Feels Like Temperature in Fahrenheit\n", .{});
try writer.print("temperature_feels_like_fahrenheit{{forecast=\"current\"}} {d}\n", .{weather.current.tempFahrenheit()});
try writer.print("# HELP cloudcover_percentage Cloud Coverage in Percent\n", .{});
try writer.print("cloudcover_percentage{{forecast=\"current\"}} 0\n", .{}); // Not in our data
try writer.print("# HELP humidity_percentage Humidity in Percent\n", .{});
try writer.print("humidity_percentage{{forecast=\"current\"}} {d}\n", .{weather.current.humidity});
try writer.print("# HELP precipitation_mm Precipitation (Rainfall) in mm\n", .{});
try writer.print("precipitation_mm{{forecast=\"current\"}} {d:.1}\n", .{weather.current.precip_mm});
try writer.print("# HELP pressure_hpa Air pressure in hPa\n", .{});
try writer.print("pressure_hpa{{forecast=\"current\"}} {d}\n", .{weather.current.pressure_mb});
try writer.print("# HELP temperature_celsius Temperature in Celsius\n", .{});
try writer.print("temperature_celsius{{forecast=\"current\"}} {d}\n", .{weather.current.temp_c});
try writer.print("# HELP temperature_fahrenheit Temperature in Fahrenheit\n", .{});
try writer.print("temperature_fahrenheit{{forecast=\"current\"}} {d}\n", .{weather.current.tempFahrenheit()});
try writer.print("# HELP uv_index Ultraviolet Radiation Index\n", .{});
try writer.print("uv_index{{forecast=\"current\"}} 0\n", .{}); // Not in our data
if (weather.current.visibility_km) |vis| {
try writer.print("# HELP visibility Visible Distance in Kilometres\n", .{});
try writer.print("visibility{{forecast=\"current\"}} {d}\n", .{vis});
}
try writer.print("# HELP weather_code Code to describe Weather Condition\n", .{});
try writer.print("weather_code{{forecast=\"current\"}} {d}\n", .{@intFromEnum(weather.current.weather_code)});
try writer.print("# HELP winddir_degree Wind Direction in Degree\n", .{});
try writer.print("winddir_degree{{forecast=\"current\"}} {d}\n", .{weather.current.wind_deg});
try writer.print("# HELP windspeed_kmph Wind Speed in Kilometres per Hour\n", .{});
try writer.print("windspeed_kmph{{forecast=\"current\"}} {d}\n", .{weather.current.wind_kph});
try writer.print("# HELP windspeed_mph Wind Speed in Miles per Hour\n", .{});
try writer.print("windspeed_mph{{forecast=\"current\"}} {d}\n", .{weather.current.windMph()});
try writer.print("# HELP observation_time Minutes since start of the day the observation happened\n", .{});
try writer.print("observation_time{{forecast=\"current\"}} 0\n", .{}); // Not tracked
try writer.print("# HELP weather_desc Weather Description\n", .{});
try writer.print("weather_desc{{forecast=\"current\", description=\"{s}\"}} 1\n", .{weather.current.condition});
try writer.print("# HELP winddir_16_point Wind Direction on a 16-wind compass rose\n", .{});
const wind_dir = degreeToDirection(weather.current.wind_deg);
try writer.print("winddir_16_point{{forecast=\"current\", description=\"{s}\"}} 1\n", .{wind_dir});
// Forecast days
for (weather.forecast, 0..) |day, i| {
const forecast_label = try std.fmt.allocPrint(allocator, "{d}d", .{i});
defer allocator.free(forecast_label);
try writer.print("uv_index{{forecast=\"{s}\"}} 0\n", .{forecast_label}); // Not in our data
try writer.print("# HELP temperature_celsius_maximum Maximum Temperature in Celsius\n", .{});
try writer.print("temperature_celsius_maximum{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, day.max_temp_c });
try writer.print("# HELP temperature_fahrenheit_maximum Maximum Temperature in Fahrenheit\n", .{});
try writer.print("temperature_fahrenheit_maximum{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, day.maxTempFahrenheit() });
try writer.print("# HELP temperature_celsius_minimum Minimum Temperature in Celsius\n", .{});
try writer.print("temperature_celsius_minimum{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, day.min_temp_c });
try writer.print("# HELP temperature_fahrenheit_minimum Minimum Temperature in Fahrenheit\n", .{});
try writer.print("temperature_fahrenheit_minimum{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, day.minTempFahrenheit() });
try writer.print("# HELP sun_hour Hours of sunlight\n", .{});
try writer.print("sun_hour{{forecast=\"{s}\"}} 0.0\n", .{forecast_label}); // Not calculated
try writer.print("# HELP snowfall_cm Total snowfall in cm\n", .{});
try writer.print("snowfall_cm{{forecast=\"{s}\"}} 0.0\n", .{forecast_label}); // Not in our data
// Moon phase - use current time for simplicity
const timestamp = std.time.timestamp();
const moon = Moon.getPhase(timestamp);
try writer.print("# HELP astronomy_moon_illumination Percentage of the moon illuminated\n", .{});
try writer.print("astronomy_moon_illumination{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, moon.illuminated * 100 });
try writer.print("# HELP astronomy_moon_phase Phase of the moon\n", .{});
try writer.print("astronomy_moon_phase{{forecast=\"{s}\", description=\"{f}\"}} 1\n", .{ forecast_label, moon });
try writer.print("# HELP astronomy_moonrise_min Minutes since start of the day until the moon appears above the horizon\n", .{});
try writer.print("astronomy_moonrise_min{{forecast=\"{s}\"}} 0\n", .{forecast_label}); // Not calculated
try writer.print("# HELP astronomy_moonset_min Minutes since start of the day until the moon disappears below the horizon\n", .{});
try writer.print("astronomy_moonset_min{{forecast=\"{s}\"}} 0\n", .{forecast_label}); // Not calculated
try writer.print("# HELP astronomy_sunrise_min Minutes since start of the day until the sun appears above the horizon\n", .{});
try writer.print("astronomy_sunrise_min{{forecast=\"{s}\"}} 0\n", .{forecast_label}); // Not calculated
try writer.print("# HELP astronomy_sunset_min Minutes since start of the day until the moon disappears below the horizon\n", .{});
try writer.print("astronomy_sunset_min{{forecast=\"{s}\"}} 0\n", .{forecast_label}); // Not calculated
}
return output.toOwnedSlice(allocator);
}
fn degreeToDirection(degrees: f64) []const u8 {
const normalized = @mod(degrees + 11.25, 360);
const index: usize = @intFromFloat(normalized / 22.5);
const directions = [_][]const u8{ "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW" };
return directions[index];
}
test "prometheus format includes required metrics" {
const allocator = std.testing.allocator;
var forecast_days = [_]types.ForecastDay{
.{
.date = .{ .year = 2024, .month = .jan, .day = 1 },
.max_temp_c = 12.0,
.min_temp_c = 5.0,
.condition = "Partly cloudy",
.weather_code = .clouds_scattered,
.hourly = &[_]types.HourlyForecast{},
},
};
const weather = types.WeatherData{
.location = "London",
.coords = .{ .latitude = 51.5074, .longitude = -0.1278 },
.current = .{
.temp_c = 10.0,
.feels_like_c = 8.0,
.condition = "Partly cloudy",
.weather_code = .clouds_scattered,
.humidity = 75,
.wind_kph = 15.0,
.wind_deg = 225.0,
.pressure_mb = 1013.0,
.precip_mm = 0.5,
.visibility_km = 10.0,
},
.forecast = &forecast_days,
.allocator = allocator,
};
const output = try render(allocator, weather);
defer allocator.free(output);
// Check for key metrics
try std.testing.expect(std.mem.indexOf(u8, output, "temperature_celsius{forecast=\"current\"}") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "humidity_percentage{forecast=\"current\"}") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "windspeed_kmph{forecast=\"current\"}") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "temperature_celsius_maximum{forecast=\"0d\"}") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "astronomy_moon_illumination{forecast=\"0d\"}") != null);
}
test "prometheus format has proper help comments" {
const allocator = std.testing.allocator;
const weather = types.WeatherData{
.location = "Test",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{
.temp_c = 20.0,
.feels_like_c = 20.0,
.condition = "Clear",
.weather_code = .clear,
.humidity = 50,
.wind_kph = 10.0,
.wind_deg = 0.0,
.pressure_mb = 1000.0,
.precip_mm = 0.0,
.visibility_km = null,
},
.forecast = &[_]types.ForecastDay{},
.allocator = allocator,
};
const output = try render(allocator, weather);
defer allocator.free(output);
// Check for HELP comments
try std.testing.expect(std.mem.indexOf(u8, output, "# HELP temperature_celsius Temperature in Celsius") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "# HELP humidity_percentage Humidity in Percent") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "# HELP pressure_hpa Air pressure in hPa") != null);
}