├── .gitignore ├── Dockerfile.release ├── .dockerignore ├── docs └── prometheus_online_count_chart.png ├── examples ├── prom-k8s │ ├── screenshot-grafana.png │ ├── configs │ │ ├── ds-prom.yaml │ │ └── prometheus.yml │ ├── kustomization.yaml │ ├── README.md │ ├── mc-monitor.yaml │ ├── prom.yaml │ └── grafana.yaml ├── mc-monitor-telegraf │ ├── telegraf.conf │ └── docker-compose.yml ├── mc-monitor-otel │ ├── prometheus.yml │ ├── grafana │ │ └── provisioning │ │ │ ├── datasources │ │ │ └── prom.yml │ │ │ └── dashboards │ │ │ └── etc.yml │ ├── otel-collector-config.yaml │ ├── README.md │ ├── docker-compose.yml │ └── dashboards │ │ └── mc-monitor.json └── mc-monitor-prom │ ├── grafana │ └── provisioning │ │ ├── datasources │ │ └── prom.yml │ │ └── dashboards │ │ └── etc.yml │ ├── prometheus.yml │ ├── README.md │ ├── docker-compose.yml │ └── dashboards │ └── mc-monitor.json ├── utils ├── errors.go └── server.go ├── .github ├── FUNDING.yml ├── workflows │ ├── test.yml │ └── release.yml └── dependabot.yml ├── Dockerfile ├── hostport.go ├── slp ├── oslp_test.go ├── slp_test.go ├── oslp.go └── slp.go ├── shared.go ├── LICENSE.txt ├── bedrock.go ├── otel ├── common.go ├── resources.go ├── metrics.go └── cmd.go ├── bedrock_status.go ├── go.mod ├── prom_cmd.go ├── telegraf.go ├── main.go ├── .goreleaser.yml ├── telegraf_cmd.go ├── prom_collector.go ├── java_status.go ├── README.md └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | *.iml 3 | 4 | /dist/ 5 | 6 | /.run/ -------------------------------------------------------------------------------- /Dockerfile.release: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | COPY mc-monitor / 3 | ENTRYPOINT ["/mc-monitor"] -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile* 2 | .git/** 3 | dist/** 4 | .goreleaser.yml 5 | .circleci 6 | examples/** 7 | docs/** 8 | README* 9 | -------------------------------------------------------------------------------- /docs/prometheus_online_count_chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itzg/mc-monitor/HEAD/docs/prometheus_online_count_chart.png -------------------------------------------------------------------------------- /examples/prom-k8s/screenshot-grafana.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itzg/mc-monitor/HEAD/examples/prom-k8s/screenshot-grafana.png -------------------------------------------------------------------------------- /examples/prom-k8s/configs/ds-prom.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - name: Prometheus 5 | type: prometheus 6 | access: proxy 7 | url: http://prometheus:9090 -------------------------------------------------------------------------------- /utils/errors.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func PrintUsageError(msg string) { 9 | _, _ = fmt.Fprintln(os.Stderr, msg) 10 | } 11 | -------------------------------------------------------------------------------- /examples/mc-monitor-telegraf/telegraf.conf: -------------------------------------------------------------------------------- 1 | [agent] 2 | interval = "10s" 3 | 4 | [[inputs.socket_listener]] 5 | service_address = "tcp://:8094" 6 | 7 | [[outputs.file]] 8 | files = ["stdout"] -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | 2 | #github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 3 | custom: 4 | - https://www.buymeacoffee.com/itzg 5 | - https://paypal.me/itzg 6 | -------------------------------------------------------------------------------- /examples/mc-monitor-otel/prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 10s 3 | scrape_configs: 4 | - job_name: otel-collector 5 | static_configs: 6 | - targets: ['otel_collector:8889'] 7 | -------------------------------------------------------------------------------- /examples/prom-k8s/configs/prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 30s 3 | scrape_configs: 4 | - job_name: mc-monitor 5 | static_configs: 6 | - targets: 7 | - mc-monitor:8080 -------------------------------------------------------------------------------- /examples/mc-monitor-otel/grafana/provisioning/datasources/prom.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - name: Prometheus 5 | type: prometheus 6 | access: proxy 7 | url: http://prometheus:9090 8 | -------------------------------------------------------------------------------- /examples/mc-monitor-prom/grafana/provisioning/datasources/prom.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - name: Prometheus 5 | type: prometheus 6 | access: proxy 7 | url: http://prometheus:9090 8 | -------------------------------------------------------------------------------- /examples/mc-monitor-prom/prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 30s 3 | scrape_configs: 4 | - job_name: mc-monitor 5 | static_configs: 6 | - targets: 7 | - monitor:8080 8 | - targets: 9 | - cadvisor:8080 -------------------------------------------------------------------------------- /examples/prom-k8s/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - prom.yaml 3 | - mc-monitor.yaml 4 | - grafana.yaml 5 | 6 | configMapGenerator: 7 | - name: prom-config 8 | files: 9 | - configs/prometheus.yml 10 | - name: grafana-datasources 11 | files: 12 | - configs/ds-prom.yaml -------------------------------------------------------------------------------- /examples/mc-monitor-otel/grafana/provisioning/dashboards/etc.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: 'etc files' 5 | type: file 6 | disableDeletion: false 7 | allowUiUpdates: true 8 | options: 9 | path: /etc/grafana/dashboards 10 | foldersFromFilesStructure: true -------------------------------------------------------------------------------- /examples/mc-monitor-prom/grafana/provisioning/dashboards/etc.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: 'etc files' 5 | type: file 6 | disableDeletion: false 7 | allowUiUpdates: true 8 | options: 9 | path: /etc/grafana/dashboards 10 | foldersFromFilesStructure: true -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23 AS builder 2 | 3 | WORKDIR /build 4 | 5 | COPY go.mod go.sum ./ 6 | RUN go mod download 7 | 8 | COPY . . 9 | RUN CGO_ENABLED=0 go build -buildvcs=false -o mc-monitor 10 | 11 | FROM scratch 12 | ENTRYPOINT ["/mc-monitor"] 13 | COPY --from=builder /build/mc-monitor /mc-monitor 14 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | paths-ignore: 7 | - README.md 8 | pull_request: 9 | branches: [ master ] 10 | schedule: 11 | - cron: 0 4 * * SUN 12 | 13 | jobs: 14 | 15 | build: 16 | uses: itzg/github-workflows/.github/workflows/go-test.yml@main 17 | with: 18 | go-version: "1.25.3" 19 | -------------------------------------------------------------------------------- /examples/mc-monitor-telegraf/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | telegraf: 5 | image: telegraf:1.13 6 | ports: 7 | - 8094:8094 8 | volumes: 9 | - ./telegraf.conf:/etc/telegraf/telegraf.conf:ro 10 | monitor: 11 | build: 12 | context: ../.. 13 | command: gather-for-telegraf 14 | environment: 15 | GATHER_INTERVAL: 10s 16 | GATHER_TELEGRAF_ADDRESS: telegraf:8094 17 | GATHER_SERVERS: mc.hypixel.net -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+" 7 | - "[0-9]+.[0-9]+.[0-9]+-*" 8 | 9 | jobs: 10 | 11 | build: 12 | uses: itzg/github-workflows/.github/workflows/go-with-releaser-image.yml@main 13 | with: 14 | go-version: "1.25.3" 15 | secrets: 16 | image-registry-username: ${{ secrets.DOCKERHUB_USERNAME }} 17 | image-registry-password: ${{ secrets.DOCKERHUB_TOKEN }} 18 | scoop-tap-github-token: ${{ secrets.PUSH_GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | patches: 9 | patterns: 10 | - "*" 11 | update-types: 12 | - patch 13 | - minor 14 | - package-ecosystem: "gomod" 15 | directory: "/" 16 | schedule: 17 | interval: "weekly" 18 | groups: 19 | patches: 20 | patterns: 21 | - "*" 22 | update-types: 23 | - patch 24 | -------------------------------------------------------------------------------- /hostport.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // @deprecated use utils.SplitHostPort instead 10 | func SplitHostPort(hostport string, defaultPort uint16) (string, uint16, error) { 11 | parts := strings.SplitN(hostport, ":", 2) 12 | if len(parts) == 2 { 13 | parsed, err := strconv.ParseUint(parts[1], 10, 16) 14 | if err != nil { 15 | return "", 0, err 16 | } 17 | return parts[0], uint16(parsed), nil 18 | } else { 19 | return parts[0], defaultPort, nil 20 | } 21 | } 22 | 23 | // @deprecated use utils.NormalizeHostPort instead 24 | func NormalizeHostPort(hostport string, defaultPort uint16) string { 25 | if strings.Contains(hostport, ":") { 26 | return hostport 27 | } else { 28 | return fmt.Sprintf("%s:%d", hostport, defaultPort) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /slp/oslp_test.go: -------------------------------------------------------------------------------- 1 | package slp 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func Test_encodePingOld(t *testing.T) { 11 | type args struct { 12 | host string 13 | port int 14 | } 15 | tests := []struct { 16 | name string 17 | args args 18 | err error 19 | frame string 20 | }{ 21 | { 22 | name: "from spec", 23 | args: args{ 24 | host: "localhost", 25 | port: 25565, 26 | }, 27 | err: nil, 28 | frame: "fe", 29 | }, 30 | } 31 | for _, tt := range tests { 32 | t.Run(tt.name, func(t *testing.T) { 33 | buf := new(bytes.Buffer) 34 | gotErr := encodePingOld(buf, tt.args.host, tt.args.port) 35 | 36 | if (tt.err != nil) != (gotErr != nil) { 37 | t.Errorf("expected %v, got %v", tt.err, gotErr) 38 | } 39 | formatted := fmt.Sprintf("%x", buf.Bytes()) 40 | assert.Equal(t, tt.frame, formatted) 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /shared.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | const ( 9 | // @deprecated use utils.DefaultJavaPort instead 10 | DefaultJavaPort uint16 = 25565 11 | // @deprecated use utils.DefaultBedrockPort instead 12 | DefaultBedrockPort uint16 = 19132 13 | ) 14 | 15 | // @deprecated use utils.ServerEdition instead 16 | type ServerEdition string 17 | 18 | const ( 19 | // @deprecated use utils.JavaEdition instead 20 | JavaEdition ServerEdition = "java" 21 | // @deprecated use utils.BedrockEdition instead 22 | BedrockEdition ServerEdition = "bedrock" 23 | ) 24 | 25 | // @deprecated use utils.ServerEdition instead 26 | func ValidEdition(v string) bool { 27 | switch ServerEdition(v) { 28 | case JavaEdition, BedrockEdition: 29 | return true 30 | } 31 | return false 32 | } 33 | 34 | // @deprecated use utils.PrintUsageError instead 35 | func printUsageError(msg string) { 36 | _, _ = fmt.Fprintln(os.Stderr, msg) 37 | } 38 | -------------------------------------------------------------------------------- /examples/prom-k8s/README.md: -------------------------------------------------------------------------------- 1 | This example uses [kustomize](https://kustomize.io/), which is built into `kubectl` and the example can be deployed using 2 | 3 | kubectl apply -k . 4 | 5 | It deploys 6 | - mc-monitor : configured to monitor a couple of public Minecraft servers 7 | - prometheus : includes a static config to scrape from mc-monitor 8 | - grafana : pre-configured with prometheus as a datasource 9 | 10 | _NOTE: prometheus and grafana are single-replica stateful sets with a 1Gi volume each._ 11 | 12 | It also deploys a service type of `LoadBalancer` for grafana at port 3000. You will need to use `kubectl get service` to see what IP address was allocated by your provider's load balancer. Follow [the getting started instructions](https://grafana.com/docs/grafana/v6.6/guides/getting_started/#log-in-for-the-first-time) for the initial login information. 13 | 14 | The following shows a screenshot of exploring the minecraft status metrics: 15 | 16 | ![](screenshot-grafana.png) -------------------------------------------------------------------------------- /examples/prom-k8s/mc-monitor.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: mc-monitor 6 | labels: 7 | app: mc-monitor 8 | spec: 9 | replicas: 1 10 | template: 11 | metadata: 12 | name: mc-monitor 13 | labels: 14 | app: mc-monitor 15 | spec: 16 | containers: 17 | - name: main 18 | image: itzg/mc-monitor 19 | env: 20 | - name: DEBUG 21 | value: "true" 22 | - name: EXPORT_SERVERS 23 | value: mc.hypixel.net,play.cubecraft.net 24 | - name: EXPORT_BEDROCK_SERVERS 25 | value: play.fallentech.io 26 | args: 27 | - export-for-prometheus 28 | restartPolicy: Always 29 | selector: 30 | matchLabels: 31 | app: mc-monitor 32 | --- 33 | apiVersion: v1 34 | kind: Service 35 | metadata: 36 | name: mc-monitor 37 | spec: 38 | selector: 39 | app: mc-monitor 40 | ports: 41 | - port: 8080 -------------------------------------------------------------------------------- /examples/mc-monitor-otel/otel-collector-config.yaml: -------------------------------------------------------------------------------- 1 | receivers: 2 | otlp: 3 | protocols: 4 | grpc: 5 | endpoint: 0.0.0.0:4317 6 | 7 | processors: 8 | batch: 9 | 10 | exporters: 11 | # In this case, we're creating a exporter for Prometheus. 12 | # However, you can use any other exporter that you want: https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/exporter 13 | prometheus: 14 | endpoint: "0.0.0.0:8889" 15 | send_timestamps: true 16 | metric_expiration: 180m 17 | enable_open_metrics: true 18 | add_metric_suffixes: false 19 | 20 | debug: 21 | # Available values: basic, normal and detailed 22 | verbosity: basic 23 | 24 | service: 25 | pipelines: 26 | # For now, we want to export only metrics. In the future, maybe, we can export logs or even traces too. 27 | # https://github.com/open-telemetry/opentelemetry-collector/tree/main/exporter 28 | metrics: 29 | receivers: [otlp] 30 | processors: [batch] 31 | exporters: [debug, prometheus] -------------------------------------------------------------------------------- /examples/mc-monitor-prom/README.md: -------------------------------------------------------------------------------- 1 | ## Setup 2 | 3 | Either git clone [the repo](https://github.com/itzg/mc-monitor) or [download a zip of the latest files](https://github.com/itzg/mc-monitor/archive/refs/heads/master.zip). 4 | 5 | Go into the `examples/mc-monitor-prom` directory and start the composition using: 6 | 7 | ```shell 8 | docker-compose up -d 9 | ``` 10 | 11 | Use `docker-compose logs -f` to watch the logs of the containers. When the Minecraft server with service name `mc` is up and running, then move onto the next step. 12 | 13 | ## Accessing Grafana 14 | 15 | Open a browser to and log in initially with the username "admin" and password "admin". You will then be prompted to create a new password for the admin user. 16 | 17 | [A dashboard called "MC Monitor"](http://localhost:3000/d/PpzSgJAnk/mc-monitor?orgId=1) is provisioned and can be accessed [in the dashboards section](http://localhost:3000/dashboards). That dashboard uses the Prometheus [datasource](http://localhost:3000/datasources) that was provisioned. 18 | 19 | -------------------------------------------------------------------------------- /examples/mc-monitor-otel/README.md: -------------------------------------------------------------------------------- 1 | ## Setup 2 | 3 | Either git clone [the repo](https://github.com/itzg/mc-monitor) or [download a zip of the latest files](https://github.com/itzg/mc-monitor/archive/refs/heads/master.zip). 4 | 5 | Go into the `examples/mc-monitor-otel` directory and start the composition using: 6 | 7 | ```shell 8 | docker-compose up -d 9 | ``` 10 | 11 | Use `docker-compose logs -f` to watch the logs of the containers. When the Minecraft server with service name `mc` is up and running, then move onto the next step. 12 | 13 | ## Accessing Grafana 14 | 15 | Open a browser to and log in initially with the username "admin" and password "admin". You will then be prompted to create a new password for the admin user. 16 | 17 | [A dashboard called "MC Monitor"](http://localhost:3000/d/PpzSgJAnk/mc-monitor?orgId=1) is provisioned and can be accessed [in the dashboards section](http://localhost:3000/dashboards). That dashboard uses the Prometheus [datasource](http://localhost:3000/datasources) that was provisioned by the collector. 18 | 19 | -------------------------------------------------------------------------------- /utils/server.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | type ServerEdition string 10 | 11 | const ( 12 | JavaEdition ServerEdition = "java" 13 | BedrockEdition ServerEdition = "bedrock" 14 | 15 | DefaultJavaPort uint16 = 25565 16 | DefaultBedrockPort uint16 = 19132 17 | ) 18 | 19 | func ValidEdition(v string) bool { 20 | switch ServerEdition(v) { 21 | case JavaEdition, BedrockEdition: 22 | return true 23 | } 24 | return false 25 | } 26 | 27 | func SplitHostPort(hostport string, defaultPort uint16) (string, uint16, error) { 28 | parts := strings.SplitN(hostport, ":", 2) 29 | if len(parts) == 2 { 30 | parsed, err := strconv.ParseUint(parts[1], 10, 16) 31 | if err != nil { 32 | return "", 0, err 33 | } 34 | return parts[0], uint16(parsed), nil 35 | } else { 36 | return parts[0], defaultPort, nil 37 | } 38 | } 39 | 40 | func NormalizeHostPort(hostport string, defaultPort uint16) string { 41 | if strings.Contains(hostport, ":") { 42 | return hostport 43 | } else { 44 | return fmt.Sprintf("%s:%d", hostport, defaultPort) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /examples/prom-k8s/prom.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: StatefulSet 4 | metadata: 5 | labels: 6 | app: prometheus 7 | name: prometheus 8 | spec: 9 | replicas: 1 10 | serviceName: prometheus 11 | selector: 12 | matchLabels: 13 | app: prometheus 14 | template: 15 | metadata: 16 | labels: 17 | app: prometheus 18 | spec: 19 | containers: 20 | - name: main 21 | image: prom/prometheus:v2.16.0 22 | volumeMounts: 23 | - mountPath: /prometheus 24 | name: data 25 | - mountPath: /etc/prometheus 26 | name: config 27 | volumes: 28 | - name: config 29 | configMap: 30 | name: prom-config 31 | volumeClaimTemplates: 32 | - metadata: 33 | name: data 34 | spec: 35 | accessModes: 36 | - ReadWriteOnce 37 | resources: 38 | requests: 39 | storage: 1Gi 40 | --- 41 | apiVersion: v1 42 | kind: Service 43 | metadata: 44 | name: prometheus 45 | spec: 46 | selector: 47 | app: prometheus 48 | ports: 49 | - port: 9090 -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Geoff Bourne 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /examples/prom-k8s/grafana.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: StatefulSet 4 | metadata: 5 | labels: 6 | app: grafana 7 | name: grafana 8 | spec: 9 | replicas: 1 10 | serviceName: grafana 11 | selector: 12 | matchLabels: 13 | app: grafana 14 | template: 15 | metadata: 16 | labels: 17 | app: grafana 18 | spec: 19 | containers: 20 | - name: main 21 | image: grafana/grafana:6.7.1 22 | volumeMounts: 23 | - mountPath: /var/lib/grafana 24 | name: storage 25 | - mountPath: /etc/grafana/provisioning/datasources 26 | name: datasources 27 | volumes: 28 | - name: datasources 29 | configMap: 30 | name: grafana-datasources 31 | volumeClaimTemplates: 32 | - metadata: 33 | name: storage 34 | spec: 35 | accessModes: 36 | - ReadWriteOnce 37 | resources: 38 | requests: 39 | storage: 1Gi 40 | --- 41 | apiVersion: v1 42 | kind: Service 43 | metadata: 44 | name: grafana 45 | spec: 46 | selector: 47 | app: grafana 48 | ports: 49 | - port: 3000 50 | type: LoadBalancer -------------------------------------------------------------------------------- /examples/mc-monitor-prom/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | mc: 5 | image: itzg/minecraft-server 6 | environment: 7 | EULA: "true" 8 | MEMORY: 2G 9 | ports: 10 | - "25565:25565" 11 | volumes: 12 | - mc-data:/data 13 | monitor: 14 | image: itzg/mc-monitor 15 | command: export-for-prometheus 16 | environment: 17 | EXPORT_SERVERS: mc 18 | DEBUG: "true" 19 | depends_on: 20 | - mc 21 | cadvisor: 22 | image: gcr.io/cadvisor/cadvisor:v0.47.1 23 | ports: 24 | - "8180:8080" 25 | volumes: 26 | - /:/rootfs:ro 27 | - /var/run:/var/run:rw 28 | - /sys:/sys:ro 29 | - /var/lib/docker/:/var/lib/docker:ro 30 | prometheus: 31 | image: prom/prometheus 32 | volumes: 33 | - ./prometheus.yml:/etc/prometheus/prometheus.yml 34 | - prometheus-tsdb:/prometheus 35 | depends_on: 36 | - monitor 37 | grafana: 38 | image: grafana/grafana-oss:${GRAFANA_VERSION:-8.3.3} 39 | ports: 40 | - "3000:3000" 41 | volumes: 42 | - grafana-lib:/var/lib/grafana 43 | - ./grafana/provisioning:/etc/grafana/provisioning 44 | - ./dashboards:/etc/grafana/dashboards 45 | depends_on: 46 | - prometheus 47 | 48 | 49 | volumes: 50 | mc-data: {} 51 | prometheus-tsdb: {} 52 | grafana-lib: {} 53 | -------------------------------------------------------------------------------- /bedrock.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/sandertv/go-raknet" 6 | "strconv" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | type BedrockServerInfo struct { 12 | ServerName string 13 | ProtocolVersion string 14 | Version string 15 | Players int 16 | MaxPlayers int 17 | LevelName string 18 | GameMode string 19 | Difficulty string 20 | Rtt time.Duration 21 | } 22 | 23 | func PingBedrockServer(address string, timeout time.Duration) (*BedrockServerInfo, error) { 24 | start := time.Now() 25 | var response []byte 26 | var err error 27 | if timeout > 0 { 28 | response, err = raknet.PingTimeout(address, timeout) 29 | } else { 30 | response, err = raknet.Ping(address) 31 | } 32 | rtt := time.Now().Sub(start) 33 | if err != nil { 34 | return nil, fmt.Errorf("failed to query bedrock server %s: %w", address, err) 35 | } 36 | 37 | parts := strings.Split(string(response), ";") 38 | info := &BedrockServerInfo{ 39 | Rtt: rtt, 40 | ServerName: parts[1], 41 | ProtocolVersion: parts[2], 42 | Version: parts[3], 43 | Players: safeParseInt(parts[4]), 44 | MaxPlayers: safeParseInt(parts[5]), 45 | LevelName: parts[7], 46 | GameMode: parts[8], 47 | } 48 | if len(parts) >= 10 { 49 | info.Difficulty = parts[9] 50 | } 51 | 52 | return info, nil 53 | } 54 | 55 | func safeParseInt(s string) int { 56 | i, err := strconv.Atoi(s) 57 | if err != nil { 58 | return -1 59 | } else { 60 | return i 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /otel/common.go: -------------------------------------------------------------------------------- 1 | package otel 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "go.opentelemetry.io/otel" 8 | "go.opentelemetry.io/otel/attribute" 9 | "go.opentelemetry.io/otel/metric" 10 | ) 11 | 12 | var ( 13 | meter = otel.GetMeterProvider().Meter("minecraft") 14 | ) 15 | 16 | func NewInt64ObservableGauge( 17 | name string, 18 | description string, 19 | callback func() int64, 20 | attributes []attribute.KeyValue, 21 | ) { 22 | _, err := meter.Int64ObservableGauge( 23 | name, 24 | metric.WithDescription(description), 25 | metric.WithUnit("1"), 26 | metric.WithInt64Callback( 27 | func(ctx context.Context, observer metric.Int64Observer) error { 28 | observer.Observe(callback(), metric.WithAttributes(attributes...)) 29 | return nil 30 | }, 31 | ), 32 | ) 33 | handleError(fmt.Sprintf("Error creating %s metric", name), err) 34 | return 35 | } 36 | 37 | func NewFloat64ObservableGauge( 38 | name string, 39 | description string, 40 | callback func() float64, 41 | attributes []attribute.KeyValue, 42 | ) { 43 | _, err := meter.Float64ObservableGauge( 44 | name, 45 | metric.WithDescription(description), 46 | metric.WithUnit("ms"), 47 | metric.WithFloat64Callback( 48 | func(ctx context.Context, observer metric.Float64Observer) error { 49 | observer.Observe(callback(), metric.WithAttributes(attributes...)) 50 | return nil 51 | }, 52 | ), 53 | ) 54 | handleError(fmt.Sprintf("Error creating %s metric", name), err) 55 | } 56 | 57 | func handleError(msg string, err error) { 58 | if err != nil { 59 | panic(msg + ": " + err.Error()) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /slp/slp_test.go: -------------------------------------------------------------------------------- 1 | package slp 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/stretchr/testify/assert" 7 | "io" 8 | "testing" 9 | ) 10 | 11 | func Test_encodePing(t *testing.T) { 12 | type args struct { 13 | host string 14 | port int 15 | } 16 | tests := []struct { 17 | name string 18 | args args 19 | err error 20 | frame string 21 | }{ 22 | { 23 | name: "from spec", 24 | args: args{ 25 | host: "localhost", 26 | port: 25565, 27 | }, 28 | err: nil, 29 | frame: "fe01fa000b004d0043007c0050006900" + 30 | "6e00670048006f0073007400194a0009" + 31 | "006c006f00630061006c0068006f0073" + 32 | "0074000063dd", 33 | }, 34 | } 35 | for _, tt := range tests { 36 | t.Run(tt.name, func(t *testing.T) { 37 | buf := new(bytes.Buffer) 38 | gotErr := encodePing(buf, tt.args.host, tt.args.port) 39 | 40 | if (tt.err != nil) != (gotErr != nil) { 41 | t.Errorf("expected %v, got %v", tt.err, gotErr) 42 | } 43 | formatted := fmt.Sprintf("%x", buf.Bytes()) 44 | assert.Equal(t, tt.frame, formatted) 45 | }) 46 | } 47 | } 48 | 49 | func Test_readString(t *testing.T) { 50 | type args struct { 51 | conn io.Reader 52 | } 53 | tests := []struct { 54 | name string 55 | args args 56 | err error 57 | expected string 58 | }{ 59 | { 60 | name: "from spec", 61 | args: args{ 62 | bytes.NewBuffer([]byte{0x00, 0xa7, 0x00, 0x31, 0x00, 0x00}), 63 | }, 64 | err: nil, 65 | expected: "§1", 66 | }, 67 | } 68 | for _, tt := range tests { 69 | t.Run(tt.name, func(t *testing.T) { 70 | gotStr, gotErr := readString(tt.args.conn) 71 | if (tt.err != nil) != (gotErr != nil) { 72 | t.Errorf("expected %v, got %v", tt.err, gotErr) 73 | } 74 | assert.Equal(t, tt.expected, gotStr) 75 | }) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /bedrock_status.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "github.com/google/subcommands" 8 | "github.com/itzg/go-flagsfiller" 9 | "log" 10 | "net" 11 | "strconv" 12 | "time" 13 | ) 14 | 15 | type statusBedrockCmd struct { 16 | Host string `default:"localhost"` 17 | Port int `default:"19132"` 18 | 19 | RetryInterval time.Duration `usage:"if retry-limit is non-zero, status will be retried at this interval" default:"10s"` 20 | RetryLimit int `usage:"if non-zero, failed status will be retried this many times before exiting"` 21 | } 22 | 23 | func (c *statusBedrockCmd) Name() string { 24 | return "status-bedrock" 25 | } 26 | 27 | func (c *statusBedrockCmd) Synopsis() string { 28 | return "Retrieves and displays the status of the given Minecraft Bedrock Dedicated server" 29 | } 30 | 31 | func (c *statusBedrockCmd) Usage() string { 32 | return "" 33 | } 34 | 35 | func (c *statusBedrockCmd) SetFlags(flags *flag.FlagSet) { 36 | filler := flagsfiller.New() 37 | err := filler.Fill(flags, c) 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | } 42 | 43 | func (c *statusBedrockCmd) Execute(ctx context.Context, f *flag.FlagSet, args ...interface{}) subcommands.ExitStatus { 44 | address := net.JoinHostPort(c.Host, strconv.Itoa(c.Port)) 45 | if c.RetryInterval <= 0 { 46 | c.RetryInterval = 1 * time.Second 47 | } 48 | 49 | for { 50 | info, err := PingBedrockServer(address, 0) 51 | if err != nil { 52 | if c.RetryLimit > 0 { 53 | c.RetryLimit-- 54 | time.Sleep(c.RetryInterval) 55 | continue 56 | } 57 | log.Fatal(err) 58 | return subcommands.ExitFailure 59 | } 60 | 61 | fmt.Printf("%s : version=%s online=%d max=%d", 62 | address, 63 | info.Version, info.Players, info.MaxPlayers) 64 | 65 | return subcommands.ExitSuccess 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /examples/mc-monitor-otel/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | # Initialize the server 3 | mc: 4 | image: itzg/minecraft-server 5 | environment: 6 | EULA: "true" 7 | MEMORY: 2G 8 | ports: 9 | - "25565:25565" 10 | volumes: 11 | - mc-data:/data 12 | 13 | # Initialize the monitor 14 | monitor: 15 | image: itzg/mc-monitor 16 | command: ['collect-otel', '-otel-collector-endpoint=otel_collector:4317','-otel-collector-timeout=35s'] 17 | environment: 18 | EXPORT_SERVERS: mc 19 | DEBUG: "true" 20 | depends_on: 21 | - mc 22 | 23 | # Prometheus 24 | prometheus: 25 | image: prom/prometheus 26 | volumes: 27 | - ./prometheus.yml:/etc/prometheus/prometheus.yml 28 | - prometheus-tsdb:/prometheus 29 | depends_on: 30 | - monitor 31 | 32 | # Grafana 33 | grafana: 34 | image: grafana/grafana:latest 35 | ports: 36 | - "3000:3000" 37 | volumes: 38 | - grafana-lib:/var/lib/grafana 39 | - ./grafana/provisioning:/etc/grafana/provisioning 40 | - ./dashboards:/etc/grafana/dashboards 41 | depends_on: 42 | - prometheus 43 | environment: 44 | - GF_AUTH_DISABLE_LOGIN_FORM=true 45 | - GF_AUTH_ANONYMOUS_ENABLED=true 46 | - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin 47 | - OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 48 | - OTEL_EXPORTER_OTLP_PROTOCOL=grpc 49 | 50 | # OTel Collector 51 | otel_collector: 52 | image: otel/opentelemetry-collector-contrib 53 | command: ["--config=/etc/otel-collector-config.yaml"] 54 | volumes: 55 | - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml 56 | ports: 57 | - "4317:4317" # OTLP gRPC Receiver 58 | - "8888:8888" # Metrics Exporter 59 | - "8889:8889" # Prometheus Exporter 60 | 61 | volumes: 62 | mc-data: 63 | prometheus-tsdb: 64 | grafana-lib: 65 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/itzg/mc-monitor 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.25.3 6 | 7 | require ( 8 | github.com/Raqbit/mc-pinger v0.2.4 9 | github.com/avast/retry-go v3.0.0+incompatible 10 | github.com/google/subcommands v1.2.0 11 | github.com/itzg/go-flagsfiller v1.15.0 12 | github.com/itzg/line-protocol-sender v0.1.1 13 | github.com/itzg/zapconfigs v0.1.0 14 | github.com/prometheus/client_golang v1.20.5 15 | github.com/sandertv/go-raknet v1.14.2 16 | github.com/stretchr/testify v1.10.0 17 | github.com/xrjr/mcutils v1.5.1 18 | go.opentelemetry.io/contrib/instrumentation/runtime v0.59.0 19 | go.opentelemetry.io/otel v1.34.0 20 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.34.0 21 | go.opentelemetry.io/otel/metric v1.34.0 22 | go.opentelemetry.io/otel/sdk/metric v1.34.0 23 | go.uber.org/zap v1.27.0 24 | ) 25 | 26 | require ( 27 | github.com/beorn7/perks v1.0.1 // indirect 28 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 29 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 30 | github.com/davecgh/go-spew v1.1.1 // indirect 31 | github.com/go-logr/logr v1.4.2 // indirect 32 | github.com/go-logr/stdr v1.2.2 // indirect 33 | github.com/google/uuid v1.6.0 // indirect 34 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect 35 | github.com/iancoleman/strcase v0.3.0 // indirect 36 | github.com/influxdata/line-protocol v0.0.0-20210311194329-9aa0e372d097 // indirect 37 | github.com/klauspost/compress v1.17.9 // indirect 38 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 39 | github.com/pires/go-proxyproto v0.7.0 // indirect 40 | github.com/pmezard/go-difflib v1.0.0 // indirect 41 | github.com/prometheus/client_model v0.6.1 // indirect 42 | github.com/prometheus/common v0.55.0 // indirect 43 | github.com/prometheus/procfs v0.15.1 // indirect 44 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 45 | go.opentelemetry.io/otel/sdk v1.34.0 // indirect 46 | go.opentelemetry.io/otel/trace v1.34.0 // indirect 47 | go.opentelemetry.io/proto/otlp v1.5.0 // indirect 48 | go.uber.org/multierr v1.10.0 // indirect 49 | golang.org/x/net v0.38.0 // indirect 50 | golang.org/x/sys v0.31.0 // indirect 51 | golang.org/x/text v0.23.0 // indirect 52 | google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f // indirect 53 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect 54 | google.golang.org/grpc v1.69.4 // indirect 55 | google.golang.org/protobuf v1.36.3 // indirect 56 | gopkg.in/yaml.v3 v3.0.1 // indirect 57 | ) 58 | -------------------------------------------------------------------------------- /prom_cmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "github.com/google/subcommands" 7 | "github.com/itzg/go-flagsfiller" 8 | "github.com/prometheus/client_golang/prometheus" 9 | "github.com/prometheus/client_golang/prometheus/promhttp" 10 | "go.uber.org/zap" 11 | "log" 12 | "net/http" 13 | "strconv" 14 | "time" 15 | ) 16 | 17 | const promExportPath = "/metrics" 18 | 19 | type exportPrometheusCmd struct { 20 | Servers []string `usage:"one or more [host:port] addresses of Java servers to monitor, when port is omitted 25565 is used"` 21 | BedrockServers []string `usage:"one or more [host:port] addresses of Bedrock servers to monitor, when port is omitted 19132 is used"` 22 | Port int `usage:"HTTP port where Prometheus metrics are exported" default:"8080"` 23 | Timeout time.Duration `usage:"timeout when checking each servers" default:"60s" env:"TIMEOUT"` 24 | logger *zap.Logger 25 | } 26 | 27 | func (c *exportPrometheusCmd) Name() string { 28 | return "export-for-prometheus" 29 | } 30 | 31 | func (c *exportPrometheusCmd) Synopsis() string { 32 | return "Registers an HTTP metrics endpoints for Prometheus export" 33 | } 34 | 35 | func (c *exportPrometheusCmd) Usage() string { 36 | return "" 37 | } 38 | 39 | func (c *exportPrometheusCmd) SetFlags(f *flag.FlagSet) { 40 | filler := flagsfiller.New(flagsfiller.WithEnv("Export")) 41 | err := filler.Fill(f, c) 42 | if err != nil { 43 | log.Fatal(err) 44 | } 45 | } 46 | 47 | func (c *exportPrometheusCmd) Execute(_ context.Context, _ *flag.FlagSet, args ...interface{}) subcommands.ExitStatus { 48 | if (len(c.Servers) + len(c.BedrockServers)) == 0 { 49 | printUsageError("requires at least one server") 50 | return subcommands.ExitUsageError 51 | } 52 | 53 | logger := args[0].(*zap.Logger) 54 | 55 | collectors, err := newPromCollectors(c.Servers, c.BedrockServers, logger) 56 | if err != nil { 57 | log.Fatal(err) 58 | } 59 | 60 | for i := range collectors { 61 | collectors[i].SetTimeout(c.Timeout) 62 | } 63 | 64 | err = prometheus.Register(collectors) 65 | if err != nil { 66 | log.Fatal(err) 67 | } 68 | 69 | exportAddress := ":" + strconv.Itoa(c.Port) 70 | 71 | logger.Info("exporting metrics for prometheus", 72 | zap.String("address", exportAddress), 73 | zap.String("path", promExportPath), 74 | ) 75 | 76 | http.Handle(promExportPath, promhttp.Handler()) 77 | log.Fatal(http.ListenAndServe(exportAddress, nil)) 78 | 79 | // never actually returns from ListenAndServe, so just satisfy return value 80 | return subcommands.ExitFailure 81 | } 82 | -------------------------------------------------------------------------------- /slp/oslp.go: -------------------------------------------------------------------------------- 1 | // Package slp implements Server List Ping and Old Server List Ping accepted by servers to version 1.3 2 | // before 1.6; however, modern servers also respond to it. 3 | package slp 4 | 5 | import ( 6 | "encoding/binary" 7 | "fmt" 8 | "strings" 9 | "io" 10 | "net" 11 | "strconv" 12 | "time" 13 | ) 14 | 15 | type OldServerListResponse struct { 16 | MessageOfTheDay string 17 | CurrentPlayerCount string 18 | MaxPlayers string 19 | } 20 | 21 | func OldServerListPing(host string, port int, timeout time.Duration) (*OldServerListResponse, error) { 22 | conn, err := net.Dial("tcp", net.JoinHostPort(host, strconv.Itoa(port))) 23 | if err != nil { 24 | return nil, fmt.Errorf("failed to connect: %w", err) 25 | } 26 | //goland:noinspection GoUnhandledErrorResult 27 | defer conn.Close() 28 | 29 | err = encodePingOld(conn, host, port) 30 | if err != nil { 31 | return nil, fmt.Errorf("failed to send ping: %w", err) 32 | } 33 | 34 | var packetId = make([]byte, 1) 35 | _ = conn.SetReadDeadline(time.Now().Add(timeout)) 36 | _, err = conn.Read(packetId) 37 | if err != nil { 38 | return nil, fmt.Errorf("failed to read response packet ID: %w", err) 39 | } 40 | 41 | if packetId[0] != 0xff { 42 | return nil, fmt.Errorf("invalid packet ID received from server: %x", packetId[0]) 43 | } 44 | 45 | var contentLen uint16 46 | err = binary.Read(conn, binary.BigEndian, &contentLen) 47 | if err != nil { 48 | return nil, fmt.Errorf("failed to read content length: %w", err) 49 | } 50 | 51 | serverResponse, err := readString(conn) 52 | if err != nil { 53 | return nil, fmt.Errorf("failed to read server response: %w", err) 54 | } 55 | 56 | responseSplit := strings.Split(serverResponse, "§") 57 | 58 | if len(responseSplit) < 3 { 59 | return nil, fmt.Errorf("invalid server response, expected at least 3 parts separated by '§', got %d: %q", len(responseSplit), serverResponse) 60 | } 61 | messageOfTheDay := responseSplit[0] 62 | currentPlayerCount := responseSplit[1] 63 | maxPlayers := responseSplit[2] 64 | 65 | var response OldServerListResponse 66 | response.MessageOfTheDay = messageOfTheDay 67 | response.CurrentPlayerCount = currentPlayerCount 68 | response.MaxPlayers = maxPlayers 69 | 70 | return &response, nil 71 | } 72 | 73 | func encodePingOld(conn io.Writer, host string, port int) error { 74 | //see https://c4k3.github.io/wiki.vg/Server_List_Ping.html#Beta_1.8_to_1.3 75 | err := writeBinarySlice(conn, []interface{}{ 76 | uint8(0xFE), 77 | }) 78 | if err != nil { 79 | return fmt.Errorf("failed to encode server list ping: %w", err) 80 | } 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /telegraf.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | mcpinger "github.com/Raqbit/mc-pinger" 6 | lpsender "github.com/itzg/line-protocol-sender" 7 | "go.uber.org/zap" 8 | "log" 9 | "strconv" 10 | "time" 11 | ) 12 | 13 | const ( 14 | MetricName = "minecraft_status" 15 | 16 | TagHost = "host" 17 | TagPort = "port" 18 | TagStatus = "status" 19 | TagVersion = "version" 20 | 21 | FieldError = "error" 22 | FieldOnline = "online" 23 | FieldMax = "max" 24 | FieldResponseTime = "response_time" 25 | 26 | StatusError = "error" 27 | StatusSuccess = "success" 28 | ) 29 | 30 | type TelegrafGatherer struct { 31 | host string 32 | port string 33 | pinger mcpinger.Pinger 34 | logger *zap.Logger 35 | lpClient lpsender.Client 36 | } 37 | 38 | func NewTelegrafGatherer(host string, port uint16, lpClient lpsender.Client, logger *zap.Logger) *TelegrafGatherer { 39 | return &TelegrafGatherer{ 40 | host: host, 41 | port: strconv.FormatInt(int64(port), 10), 42 | pinger: mcpinger.New(host, uint16(port)), 43 | lpClient: lpClient, 44 | logger: logger, 45 | } 46 | } 47 | 48 | func (g *TelegrafGatherer) Gather() { 49 | g.logger.Debug("gathering", zap.String("host", g.host), zap.String("port", g.port)) 50 | startTime := time.Now() 51 | info, err := g.pinger.Ping() 52 | elapsed := time.Now().Sub(startTime) 53 | 54 | if err != nil { 55 | g.sendFailedMetrics(err, elapsed) 56 | } else if info.Players.Max == 0 { 57 | g.sendFailedMetrics(errors.New("server not ready"), elapsed) 58 | } else { 59 | err := g.sendInfoMetrics(info, elapsed) 60 | if err != nil { 61 | log.Printf("failed to send metrics: %s", err) 62 | } 63 | } 64 | } 65 | 66 | func (g *TelegrafGatherer) sendInfoMetrics(info *mcpinger.ServerInfo, elapsed time.Duration) error { 67 | m := lpsender.NewSimpleMetric(MetricName) 68 | 69 | m.AddTag(TagHost, g.host) 70 | m.AddTag(TagPort, g.port) 71 | m.AddTag(TagStatus, StatusSuccess) 72 | m.AddTag(TagVersion, info.Version.Name) 73 | 74 | m.AddField(FieldResponseTime, elapsed.Seconds()) 75 | m.AddField(FieldOnline, uint64(info.Players.Online)) 76 | m.AddField(FieldMax, uint64(info.Players.Max)) 77 | 78 | g.lpClient.Send(m) 79 | 80 | return nil 81 | } 82 | 83 | func (g *TelegrafGatherer) sendFailedMetrics(err error, elapsed time.Duration) { 84 | m := lpsender.NewSimpleMetric(MetricName) 85 | 86 | m.AddTag(TagHost, g.host) 87 | m.AddTag(TagPort, g.port) 88 | m.AddTag(TagStatus, StatusError) 89 | 90 | m.AddField(FieldError, err.Error()) 91 | m.AddField(FieldResponseTime, elapsed.Seconds()) 92 | 93 | g.lpClient.Send(m) 94 | 95 | return 96 | } 97 | -------------------------------------------------------------------------------- /otel/resources.go: -------------------------------------------------------------------------------- 1 | package otel 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | 7 | mcpinger "github.com/Raqbit/mc-pinger" 8 | "github.com/itzg/mc-monitor/utils" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | type Resource interface { 13 | Execute() 14 | } 15 | 16 | type OpenTelemetryMetricResource struct { 17 | host string 18 | port uint16 19 | edition utils.ServerEdition 20 | pinger mcpinger.Pinger 21 | metrics *ServerMetrics 22 | logger *zap.Logger 23 | } 24 | 25 | type OpenTelemetryMetricResourceOptions func(r *OpenTelemetryMetricResource) 26 | 27 | func withServerEdition(edition utils.ServerEdition) OpenTelemetryMetricResourceOptions { 28 | return func(r *OpenTelemetryMetricResource) { 29 | r.edition = edition 30 | } 31 | } 32 | 33 | func withServerMetrics(logger *zap.Logger) OpenTelemetryMetricResourceOptions { 34 | return func(r *OpenTelemetryMetricResource) { 35 | r.metrics = NewServerMetrics(logger) 36 | } 37 | } 38 | 39 | func withLogger(logger *zap.Logger) OpenTelemetryMetricResourceOptions { 40 | return func(r *OpenTelemetryMetricResource) { 41 | r.logger = logger 42 | } 43 | } 44 | 45 | func newOpenTelemetryMetricResource(host string, port uint16, options ...OpenTelemetryMetricResourceOptions) ( 46 | *OpenTelemetryMetricResource, 47 | error, 48 | ) { 49 | resource := &OpenTelemetryMetricResource{ 50 | host: host, 51 | port: port, 52 | pinger: mcpinger.New(host, port), 53 | } 54 | 55 | for _, option := range options { 56 | option(resource) 57 | } 58 | 59 | return resource, nil 60 | } 61 | 62 | func (r *OpenTelemetryMetricResource) Execute() { 63 | r.logger.Debug("pinging", zap.String("host", r.host), zap.String("port", strconv.Itoa(int(r.port)))) 64 | startTime := time.Now() 65 | info, err := r.pinger.Ping() 66 | elapsed := time.Now().Sub(startTime) 67 | r.logger.Debug("ping returned", zap.Error(err), zap.Any("info", info)) 68 | r.logger.Debug("measured elapsed time", zap.Float64("elapsed", elapsed.Seconds())) 69 | 70 | if r.metrics != nil { 71 | if err != nil || info.Players.Max == 0 { 72 | r.metrics.RecordHealth(false, buildMetricAttributes(r.host, r.port, r.edition, "")) 73 | return 74 | } 75 | 76 | r.metrics.RecordResponseTime(elapsed.Seconds(), buildMetricAttributes(r.host, r.port, r.edition, info.Version.Name)) 77 | r.metrics.RecordHealth(true, buildMetricAttributes(r.host, r.port, r.edition, info.Version.Name)) 78 | r.metrics.RecordPlayersOnlineCount(info.Players.Online, buildMetricAttributes(r.host, r.port, r.edition, info.Version.Name)) 79 | r.metrics.RecordPlayersMaxCount(info.Players.Max, buildMetricAttributes(r.host, r.port, r.edition, info.Version.Name)) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | 12 | "github.com/google/subcommands" 13 | "github.com/itzg/go-flagsfiller" 14 | "github.com/itzg/mc-monitor/otel" 15 | "github.com/itzg/zapconfigs" 16 | "go.uber.org/zap" 17 | ) 18 | 19 | var ( 20 | version = "" 21 | commit = "" 22 | date = "" 23 | ) 24 | 25 | func main() { 26 | subcommands.Register(subcommands.HelpCommand(), "") 27 | subcommands.Register(subcommands.FlagsCommand(), "") 28 | subcommands.Register(&versionCmd{}, "") 29 | subcommands.Register(&statusCmd{}, "status") 30 | subcommands.Register(&statusBedrockCmd{}, "status") 31 | subcommands.Register(&gatherTelegrafCmd{}, "monitoring") 32 | subcommands.Register(&exportPrometheusCmd{}, "monitoring") 33 | subcommands.Register(&otel.CollectOpenTelemetryCmd{}, "monitoring") 34 | 35 | var config GlobalConfig 36 | err := flagsfiller.Parse(&config, flagsfiller.WithEnv("")) 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | 41 | var logger *zap.Logger 42 | if config.Debug { 43 | zapConfig := zap.Config{ 44 | Encoding: "console", 45 | EncoderConfig: zapconfigs.NewDebugEncoderConfig(), 46 | Level: zap.NewAtomicLevelAt(zap.DebugLevel), 47 | // output to stderr so that scripts grabbing output don't get logs 48 | OutputPaths: []string{"stderr"}, 49 | ErrorOutputPaths: []string{"stderr"}, 50 | } 51 | var err error 52 | logger, err = zapConfig.Build() 53 | if err != nil { 54 | log.Fatal(err) 55 | } 56 | } else { 57 | logger = zapconfigs.NewDefaultLogger() 58 | } 59 | defer logger.Sync() 60 | 61 | ctx, cancel := context.WithCancel(context.Background()) 62 | sigc := make(chan os.Signal, 1) 63 | signal.Notify( 64 | sigc, 65 | syscall.SIGINT, 66 | syscall.SIGTERM, 67 | syscall.SIGQUIT, 68 | ) 69 | go func() { 70 | <-sigc 71 | cancel() 72 | }() 73 | 74 | os.Exit(int(subcommands.Execute(ctx, logger))) 75 | } 76 | 77 | type GlobalConfig struct { 78 | Debug bool `usage:"enable debug logging"` 79 | } 80 | 81 | type versionCmd struct{} 82 | 83 | func (c *versionCmd) Name() string { 84 | return "version" 85 | } 86 | 87 | func (c *versionCmd) Synopsis() string { 88 | return "Show version and exit" 89 | } 90 | 91 | func (c *versionCmd) Usage() string { 92 | return "" 93 | } 94 | 95 | func (c *versionCmd) SetFlags(*flag.FlagSet) { 96 | } 97 | 98 | func (c *versionCmd) Execute(context.Context, *flag.FlagSet, ...interface{}) subcommands.ExitStatus { 99 | fmt.Printf("%s commit=%s date=%s\n", version, commit, date) 100 | return subcommands.ExitSuccess 101 | } 102 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | project_name: mc-monitor 4 | before: 5 | hooks: 6 | - go mod download 7 | builds: 8 | - env: 9 | - CGO_ENABLED=0 10 | goos: 11 | - linux 12 | - windows 13 | - darwin 14 | goarch: 15 | - amd64 16 | - arm 17 | - arm64 18 | goarm: 19 | - "7" 20 | ignore: 21 | - goos: windows 22 | goarch: arm 23 | dockers: 24 | - image_templates: 25 | - itzg/{{ .ProjectName }}:{{ .Version }}-amd64 26 | dockerfile: Dockerfile.release 27 | use: buildx 28 | build_flag_templates: 29 | - --platform 30 | - linux/amd64 31 | - --label=org.opencontainers.image.version={{ .Version }} 32 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 33 | - image_templates: 34 | - itzg/{{ .ProjectName }}:{{ .Version }}-arm64 35 | dockerfile: Dockerfile.release 36 | goarch: arm64 37 | use: buildx 38 | build_flag_templates: 39 | - --platform 40 | - linux/arm64 41 | - --label=org.opencontainers.image.version={{ .Version }} 42 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 43 | - image_templates: 44 | - itzg/{{ .ProjectName }}:{{ .Version }}-arm32v7 45 | dockerfile: Dockerfile.release 46 | goarch: arm 47 | goarm: "7" 48 | use: buildx 49 | build_flag_templates: 50 | - --platform 51 | - linux/arm/v7 52 | - --label=org.opencontainers.image.version={{ .Version }} 53 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 54 | docker_manifests: 55 | - name_template: itzg/{{ .ProjectName }}:{{ .Version }} 56 | image_templates: 57 | - itzg/{{ .ProjectName }}:{{ .Version }}-amd64 58 | - itzg/{{ .ProjectName }}:{{ .Version }}-arm64 59 | - itzg/{{ .ProjectName }}:{{ .Version }}-arm32v7 60 | - name_template: itzg/{{ .ProjectName }}:latest 61 | image_templates: 62 | - itzg/{{ .ProjectName }}:{{ .Version }}-amd64 63 | - itzg/{{ .ProjectName }}:{{ .Version }}-arm64 64 | - itzg/{{ .ProjectName }}:{{ .Version }}-arm32v7 65 | checksum: 66 | name_template: 'checksums.txt' 67 | changelog: 68 | sort: asc 69 | filters: 70 | exclude: 71 | - '^docs:' 72 | - '^test:' 73 | - '^ci:' 74 | - '^misc:' 75 | scoops: 76 | - repository: 77 | owner: itzg 78 | name: scoop-bucket 79 | token: "{{ .Env.SCOOP_TAP_GITHUB_TOKEN }}" 80 | directory: bucket 81 | license: MIT 82 | description: Command/agent to monitor the status of Minecraft servers 83 | brews: 84 | - repository: 85 | owner: itzg 86 | name: homebrew-tap 87 | token: "{{ .Env.SCOOP_TAP_GITHUB_TOKEN }}" 88 | license: MIT 89 | description: Command/agent to monitor the status of Minecraft servers 90 | -------------------------------------------------------------------------------- /otel/metrics.go: -------------------------------------------------------------------------------- 1 | package otel 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/itzg/mc-monitor/utils" 7 | "go.opentelemetry.io/otel/attribute" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | const ( 12 | serverHostAttribute = "server_host" 13 | serverPortAttribute = "server_port" 14 | serverEditionAttribute = "server_edition" 15 | serverVersionAttribute = "server_version" 16 | ) 17 | 18 | type ServerMetrics struct { 19 | healthy bool 20 | responseTime float64 21 | playersOnlineCount int64 22 | playersMaxCount int64 23 | logger *zap.Logger 24 | } 25 | 26 | func NewServerMetrics(logger *zap.Logger) *ServerMetrics { 27 | return &ServerMetrics{ 28 | healthy: false, 29 | responseTime: 0.0, 30 | playersOnlineCount: 0, 31 | playersMaxCount: 0, 32 | logger: logger, 33 | } 34 | } 35 | 36 | func (m *ServerMetrics) RecordHealth(healthy bool, attributes []attribute.KeyValue) { 37 | m.healthy = healthy 38 | NewInt64ObservableGauge( 39 | "minecraft_status_healthy", 40 | "Indicates if the server is healthy (1) or not (0)", 41 | func() int64 { 42 | if m.healthy { 43 | m.logger.Debug("Server is healthy") 44 | return int64(1) 45 | } 46 | m.logger.Debug("Server is not healthy") 47 | return int64(0) 48 | }, 49 | attributes, 50 | ) 51 | } 52 | 53 | func (m *ServerMetrics) RecordResponseTime(responseTime float64, attributes []attribute.KeyValue) { 54 | m.responseTime = responseTime 55 | NewFloat64ObservableGauge( 56 | "minecraft_status_response_time", 57 | "The response time of the server", 58 | func() float64 { 59 | m.logger.Debug("Response time", zap.Float64("responseTime", m.responseTime)) 60 | return m.responseTime 61 | }, 62 | attributes, 63 | ) 64 | } 65 | 66 | func (m *ServerMetrics) RecordPlayersOnlineCount(playersOnlineCount int32, attributes []attribute.KeyValue) { 67 | m.playersOnlineCount = int64(playersOnlineCount) 68 | NewInt64ObservableGauge( 69 | "minecraft_status_players_online_count", 70 | "The number of players currently online on the server", 71 | func() int64 { 72 | m.logger.Debug("PlayersOnlineCount", zap.Int64("playersOnlineCount", m.playersOnlineCount)) 73 | return m.playersOnlineCount 74 | }, 75 | attributes, 76 | ) 77 | } 78 | 79 | func (m *ServerMetrics) RecordPlayersMaxCount(playersMaxCount int32, attributes []attribute.KeyValue) { 80 | m.playersMaxCount = int64(playersMaxCount) 81 | NewInt64ObservableGauge( 82 | "minecraft_status_players_max_count", 83 | "The maximum number of players that can be online on the server", 84 | func() int64 { 85 | m.logger.Debug("PlayersMaxCount", zap.Int64("playersMaxCount", m.playersMaxCount)) 86 | return m.playersMaxCount 87 | }, 88 | attributes, 89 | ) 90 | } 91 | 92 | func buildMetricAttributes(host string, port uint16, edition utils.ServerEdition, version string) []attribute.KeyValue { 93 | return []attribute.KeyValue{ 94 | attribute.String(serverHostAttribute, host), 95 | attribute.String(serverPortAttribute, strconv.Itoa(int(port))), 96 | attribute.String(serverEditionAttribute, string(edition)), 97 | attribute.String(serverVersionAttribute, version), 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /telegraf_cmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "github.com/google/subcommands" 8 | "github.com/itzg/go-flagsfiller" 9 | lpsender "github.com/itzg/line-protocol-sender" 10 | "go.uber.org/zap" 11 | "log" 12 | "os" 13 | "time" 14 | ) 15 | 16 | type gatherTelegrafCmd struct { 17 | Interval time.Duration `default:"1m" usage:"gathers and sends metrics at this interval"` 18 | Servers []string `usage:"one or more [host:port] addresses of servers to monitor"` 19 | TelegrafAddress string `default:"localhost:8094" usage:"[host:port] of telegraf accepting Influx line protocol"` 20 | logger *zap.Logger 21 | } 22 | 23 | func (c *gatherTelegrafCmd) Name() string { 24 | return "gather-for-telegraf" 25 | } 26 | 27 | func (c *gatherTelegrafCmd) Synopsis() string { 28 | return "Periodically gathers to status of one or more Minecraft servers and sends metrics to telegraf over TCP using Influx line protocol" 29 | } 30 | 31 | func (c *gatherTelegrafCmd) Usage() string { 32 | return "" 33 | } 34 | 35 | func (c *gatherTelegrafCmd) SetFlags(f *flag.FlagSet) { 36 | filler := flagsfiller.New(flagsfiller.WithEnv("Gather")) 37 | err := filler.Fill(f, c) 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | } 42 | 43 | func (c *gatherTelegrafCmd) Execute(ctx context.Context, _ *flag.FlagSet, args ...interface{}) subcommands.ExitStatus { 44 | 45 | if len(c.Servers) == 0 { 46 | _, _ = fmt.Fprintln(os.Stderr, "requires at least one server") 47 | return subcommands.ExitUsageError 48 | } 49 | 50 | if c.TelegrafAddress == "" { 51 | _, _ = fmt.Fprintln(os.Stderr, "requires TelegrafAddress") 52 | return subcommands.ExitUsageError 53 | } 54 | 55 | c.logger = args[0].(*zap.Logger).Named("gather") 56 | 57 | c.logger.Info("starting monitoring", 58 | zap.Strings("servers", c.Servers), 59 | zap.Duration("interval", c.Interval), 60 | zap.String("telegrafAddress", c.TelegrafAddress)) 61 | 62 | ticker := time.NewTicker(c.Interval) 63 | 64 | gatherers, err := c.createGatherers() 65 | if err != nil { 66 | c.logger.Error("failed to setup gatherers", zap.Error(err)) 67 | return subcommands.ExitFailure 68 | } 69 | 70 | for { 71 | select { 72 | case <-ctx.Done(): 73 | return subcommands.ExitSuccess 74 | 75 | case <-ticker.C: 76 | for _, gatherer := range gatherers { 77 | gatherer.Gather() 78 | } 79 | } 80 | } 81 | } 82 | 83 | func (c *gatherTelegrafCmd) createGatherers() ([]*TelegrafGatherer, error) { 84 | gatherers := make([]*TelegrafGatherer, 0, len(c.Servers)) 85 | 86 | lpClient, err := lpsender.NewClient(context.Background(), lpsender.Config{ 87 | Endpoint: c.TelegrafAddress, 88 | BatchSize: len(c.Servers), 89 | ErrorListener: func(err error) { 90 | c.logger.Error("failed to send metrics", zap.Error(err)) 91 | }, 92 | }) 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | for _, addr := range c.Servers { 98 | host, port, err := SplitHostPort(addr, DefaultJavaPort) 99 | if err != nil { 100 | return nil, err 101 | } 102 | gatherers = append(gatherers, NewTelegrafGatherer(host, port, lpClient, c.logger)) 103 | } 104 | 105 | return gatherers, nil 106 | } 107 | -------------------------------------------------------------------------------- /slp/slp.go: -------------------------------------------------------------------------------- 1 | // Package slp implements the Server List Ping and Old Server List Ping protocol originally accepted by servers 2 | // before 1.6; however, modern servers also respond to it. 3 | // Old Server List Ping is used for pre-1.3 versions 4 | package slp 5 | 6 | import ( 7 | "bytes" 8 | "encoding/binary" 9 | "fmt" 10 | "io" 11 | "net" 12 | "strconv" 13 | "time" 14 | "unicode/utf16" 15 | ) 16 | 17 | type ServerListResponse struct { 18 | ProtocolVersion string 19 | ServerVersion string 20 | MessageOfTheDay string 21 | CurrentPlayerCount string 22 | MaxPlayers string 23 | } 24 | 25 | func ServerListPing(host string, port int, timeout time.Duration) (*ServerListResponse, error) { 26 | conn, err := net.Dial("tcp", net.JoinHostPort(host, strconv.Itoa(port))) 27 | if err != nil { 28 | return nil, fmt.Errorf("failed to connect: %w", err) 29 | } 30 | //goland:noinspection GoUnhandledErrorResult 31 | defer conn.Close() 32 | 33 | err = encodePing(conn, host, port) 34 | if err != nil { 35 | return nil, fmt.Errorf("failed to send ping: %w", err) 36 | } 37 | 38 | var packetId = make([]byte, 1) 39 | _ = conn.SetReadDeadline(time.Now().Add(timeout)) 40 | _, err = conn.Read(packetId) 41 | if err != nil { 42 | return nil, fmt.Errorf("failed to read response packet ID: %w", err) 43 | } 44 | 45 | if packetId[0] != 0xff { 46 | return nil, fmt.Errorf("invalid packet ID received from server: %x", packetId[0]) 47 | } 48 | 49 | var contentLen uint16 50 | err = binary.Read(conn, binary.BigEndian, &contentLen) 51 | if err != nil { 52 | return nil, fmt.Errorf("failed to read content length: %w", err) 53 | } 54 | 55 | header, err := readString(conn) 56 | if err != nil { 57 | return nil, fmt.Errorf("failed to read header: %w", err) 58 | } 59 | if header != "§1" { 60 | return nil, fmt.Errorf("invalid response header: %s", header) 61 | } 62 | 63 | protocolVersion, err := readString(conn) 64 | if err != nil { 65 | return nil, fmt.Errorf("failed to read protocolVersion: %w", err) 66 | } 67 | serverVersion, err := readString(conn) 68 | if err != nil { 69 | return nil, fmt.Errorf("failed to read serverVersion: %w", err) 70 | } 71 | messageOfTheDay, err := readString(conn) 72 | if err != nil { 73 | return nil, fmt.Errorf("failed to read messageOfTheDay: %w", err) 74 | } 75 | currentPlayerCount, err := readString(conn) 76 | if err != nil { 77 | return nil, fmt.Errorf("failed to read currentPlayerCount: %w", err) 78 | } 79 | maxPlayers, err := readString(conn) 80 | if err != nil { 81 | return nil, fmt.Errorf("failed to read maxPlayers: %w", err) 82 | } 83 | 84 | var response ServerListResponse 85 | response.ProtocolVersion = protocolVersion 86 | response.ServerVersion = serverVersion 87 | response.MessageOfTheDay = messageOfTheDay 88 | response.CurrentPlayerCount = currentPlayerCount 89 | response.MaxPlayers = maxPlayers 90 | 91 | return &response, nil 92 | } 93 | 94 | // readString reads a null terminated UTF-16BE string from the reader 95 | func readString(conn io.Reader) (string, error) { 96 | chars := make([]uint16, 0) 97 | 98 | var next uint16 99 | for { 100 | err := binary.Read(conn, binary.BigEndian, &next) 101 | if err != nil { 102 | if err == io.EOF { 103 | break 104 | } 105 | return "", err 106 | } else if next == 0 { 107 | break 108 | } else { 109 | chars = append(chars, next) 110 | } 111 | } 112 | 113 | return string(utf16.Decode(chars)), nil 114 | } 115 | 116 | func encodePing(conn io.Writer, host string, port int) error { 117 | // see https://wiki.vg/Server_List_Ping#Client_to_server 118 | err := writeBinarySlice(conn, []interface{}{ 119 | uint8(0xFE), 120 | uint8(1), 121 | uint8(0xFA), 122 | uint16(11), 123 | }) 124 | if err != nil { 125 | return fmt.Errorf("failed to encode server list ping: %w", err) 126 | } 127 | err = writeBinaryUtf16(conn, utf16.Encode([]rune("MC|PingHost"))) 128 | if err != nil { 129 | return fmt.Errorf("failed to encode server list ping: %w", err) 130 | } 131 | hostEncoded := new(bytes.Buffer) 132 | _ = writeBinaryUtf16(hostEncoded, utf16.Encode([]rune(host))) 133 | err = writeBinarySlice(conn, []interface{}{ 134 | uint16(7 + hostEncoded.Len()), 135 | uint8(74), 136 | // length of following string, in characters, as a short 137 | uint16(len(host)), 138 | }) 139 | if err != nil { 140 | return fmt.Errorf("failed to encode server list ping: %w", err) 141 | } 142 | _, err = io.Copy(conn, hostEncoded) 143 | if err != nil { 144 | return fmt.Errorf("failed to write host: %w", err) 145 | } 146 | err = binary.Write(conn, binary.BigEndian, uint32(port)) 147 | if err != nil { 148 | return fmt.Errorf("failed to encode server list ping: %w", err) 149 | } 150 | 151 | return nil 152 | } 153 | 154 | func writeBinaryUtf16(dst io.Writer, encoded []uint16) error { 155 | for _, v := range encoded { 156 | err := binary.Write(dst, binary.BigEndian, v) 157 | if err != nil { 158 | return err 159 | } 160 | } 161 | return nil 162 | } 163 | 164 | func writeBinarySlice(dst io.Writer, data []interface{}) error { 165 | for _, v := range data { 166 | err := binary.Write(dst, binary.BigEndian, v) 167 | if err != nil { 168 | return err 169 | } 170 | } 171 | return nil 172 | } 173 | -------------------------------------------------------------------------------- /otel/cmd.go: -------------------------------------------------------------------------------- 1 | package otel 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "time" 9 | 10 | "github.com/google/subcommands" 11 | "github.com/itzg/go-flagsfiller" 12 | "github.com/itzg/mc-monitor/utils" 13 | "go.opentelemetry.io/contrib/instrumentation/runtime" 14 | "go.opentelemetry.io/otel" 15 | "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" 16 | "go.opentelemetry.io/otel/sdk/metric" 17 | "go.uber.org/zap" 18 | ) 19 | 20 | type CollectOpenTelemetryCmd struct { 21 | Servers []string `usage:"one or more [host:port] addresses of Java servers to monitor, when port is omitted 25565 is used"` 22 | BedrockServers []string `usage:"one or more [host:port] addresses of Bedrock servers to monitor, when port is omitted 19132 is used"` 23 | Interval time.Duration `default:"10s" usage:"Collect and sends OpenTelemetry data at this interval"` 24 | OtelCollector Collector `group:"exporter" namespace:"exporter" usage:"Open Telemetry OtelCollector configurations"` 25 | logger *zap.Logger 26 | } 27 | 28 | type Collector struct { 29 | Endpoint string `default:"localhost:4317" usage:"OpenTelemetry gRPC endpoint to export data"` 30 | Timeout time.Duration `default:"35s" usage:"Timeout for collecting OpenTelemetry data"` 31 | } 32 | 33 | // ShutdownFunc is a function that can be called to shut down the Open Telemetry provider components 34 | type ShutdownFunc func() error 35 | 36 | var _histogramBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10, 25, 50, 100} 37 | 38 | func (c *CollectOpenTelemetryCmd) Name() string { 39 | return "collect-otel" 40 | } 41 | 42 | func (c *CollectOpenTelemetryCmd) Synopsis() string { 43 | return "Starts collecting telemetry data using OpenTelemetry" 44 | } 45 | 46 | func (c *CollectOpenTelemetryCmd) Usage() string { 47 | return "" 48 | } 49 | 50 | func (c *CollectOpenTelemetryCmd) SetFlags(f *flag.FlagSet) { 51 | filler := flagsfiller.New(flagsfiller.WithEnv("Export")) 52 | err := filler.Fill(f, c) 53 | if err != nil { 54 | log.Fatal(err) 55 | } 56 | } 57 | 58 | func (c *CollectOpenTelemetryCmd) Execute(ctx context.Context, _ *flag.FlagSet, args ...interface{}) subcommands.ExitStatus { 59 | // Validate the command line arguments 60 | if (len(c.Servers) + len(c.BedrockServers)) == 0 { 61 | utils.PrintUsageError("requires at least one server") 62 | return subcommands.ExitUsageError 63 | } 64 | 65 | if c.OtelCollector.Endpoint == "" { 66 | utils.PrintUsageError("the open telemetry endpoint must be set") 67 | return subcommands.ExitUsageError 68 | } 69 | 70 | // Start the OpenTelemetry meter provider 71 | meterShutdownFunc, err := c.startMeterProvider(ctx) 72 | if err != nil { 73 | utils.PrintUsageError(fmt.Sprintf("failed to start meter provider: %v", err)) 74 | return subcommands.ExitFailure 75 | } 76 | 77 | // Set the logger for the OpenTelemetry components 78 | c.logger = args[0].(*zap.Logger).Named("otel") 79 | 80 | // Create the resources to be monitored 81 | resources, err := c.initializeMetricResources() 82 | if err != nil { 83 | utils.PrintUsageError(fmt.Sprintf("failed to create metric checker: %v", err)) 84 | return subcommands.ExitFailure 85 | } 86 | 87 | // Start the observing loop 88 | ticker := time.NewTicker(c.Interval) 89 | 90 | for { 91 | select { 92 | case <-ctx.Done(): 93 | if err := meterShutdownFunc(); err != nil { 94 | return subcommands.ExitFailure 95 | } 96 | 97 | return subcommands.ExitSuccess 98 | 99 | case <-ticker.C: 100 | c.logger.Info("collecting OpenTelemetry data") 101 | 102 | for _, r := range resources { 103 | go r.Execute() 104 | } 105 | } 106 | } 107 | } 108 | 109 | // startMeterProvider constructs and starts the exporter that will be sending telemetry data from a meter provider that is set 110 | func (c *CollectOpenTelemetryCmd) startMeterProvider(ctx context.Context) (ShutdownFunc, error) { 111 | exporter, err := otlpmetricgrpc.New(ctx, otlpmetricgrpc.WithEndpoint(c.OtelCollector.Endpoint), otlpmetricgrpc.WithInsecure()) 112 | if err != nil { 113 | return nil, err 114 | } 115 | 116 | meterProvider := metric.NewMeterProvider( 117 | metric.WithReader( 118 | metric.NewPeriodicReader( 119 | exporter, 120 | metric.WithTimeout(c.OtelCollector.Timeout), 121 | metric.WithInterval(c.Interval), 122 | ), 123 | ), 124 | metric.WithView( 125 | metric.NewView( 126 | metric.Instrument{ 127 | Name: "*", 128 | Kind: metric.InstrumentKindHistogram, 129 | }, 130 | metric.Stream{Aggregation: metric.AggregationExplicitBucketHistogram{Boundaries: _histogramBuckets}}, 131 | ), 132 | ), 133 | ) 134 | 135 | otel.SetMeterProvider(meterProvider) 136 | 137 | err = runtime.Start(runtime.WithMinimumReadMemStatsInterval(c.Interval)) 138 | if err != nil { 139 | return nil, err 140 | } 141 | 142 | return func() error { 143 | return meterProvider.Shutdown(ctx) 144 | }, nil 145 | } 146 | 147 | // initializeMetricResources creates the OpenTelemetry Metric resources for the given servers 148 | func (c *CollectOpenTelemetryCmd) initializeMetricResources() ( 149 | []Resource, 150 | error, 151 | ) { 152 | resources := make([]Resource, 0) 153 | 154 | for _, server := range c.Servers { 155 | host, port, err := utils.SplitHostPort(server, utils.DefaultJavaPort) 156 | if err != nil { 157 | return nil, fmt.Errorf("failed to process server entry '%s': %w", server, err) 158 | } 159 | c.logger.Info("adding Java server", zap.String("host", host), zap.Uint16("port", port)) 160 | 161 | resource, err := newOpenTelemetryMetricResource( 162 | host, 163 | port, 164 | withServerEdition(utils.JavaEdition), 165 | withServerMetrics(c.logger), 166 | withLogger(c.logger), 167 | ) 168 | if err != nil { 169 | return nil, fmt.Errorf("failed to create Java resource: %w", err) 170 | } 171 | resources = append(resources, resource) 172 | } 173 | 174 | for _, server := range c.BedrockServers { 175 | host, port, err := utils.SplitHostPort(server, utils.DefaultBedrockPort) 176 | if err != nil { 177 | return nil, fmt.Errorf("failed to process server entry '%s': %w", server, err) 178 | } 179 | c.logger.Info("adding Bedrock server", zap.String("host", host), zap.Uint16("port", port)) 180 | 181 | resource, err := newOpenTelemetryMetricResource( 182 | host, 183 | port, 184 | withServerEdition(utils.BedrockEdition), 185 | withLogger(c.logger), 186 | ) 187 | if err != nil { 188 | return nil, fmt.Errorf("failed to create Bedrock resource: %w", err) 189 | } 190 | resources = append(resources, resource) 191 | } 192 | 193 | return resources, nil 194 | } 195 | -------------------------------------------------------------------------------- /prom_collector.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | mcpinger "github.com/Raqbit/mc-pinger" 6 | "github.com/prometheus/client_golang/prometheus" 7 | "go.uber.org/zap" 8 | "net" 9 | "strconv" 10 | "time" 11 | ) 12 | 13 | const ( 14 | promLabelHost = "server_host" 15 | promLabelPort = "server_port" 16 | promLabelEdition = "server_edition" 17 | promLabelVersion = "server_version" 18 | ) 19 | 20 | var ( 21 | promVariableLabels = []string{promLabelHost, promLabelPort, promLabelEdition, promLabelVersion} 22 | promDescHealthy = prometheus.NewDesc("minecraft_status_healthy", 23 | "Indicates if the server is healthy (1) or not (0)", 24 | promVariableLabels, nil) 25 | promDescResponseTime = prometheus.NewDesc("minecraft_status_response_time_seconds", 26 | "Amount of time it took for server to respond", 27 | promVariableLabels, nil) 28 | promDescPlayersOnline = prometheus.NewDesc("minecraft_status_players_online_count", 29 | "Number of players currently online", 30 | promVariableLabels, nil) 31 | promDescPlayersMax = prometheus.NewDesc("minecraft_status_players_max_count", 32 | "Maximum number of players allowed by the server", 33 | promVariableLabels, nil) 34 | ) 35 | 36 | type pingOptions interface { 37 | GetHost() string 38 | GetPort() uint16 39 | GetTimeout() time.Duration 40 | } 41 | 42 | func pingJavaServer(opt pingOptions) (*mcpinger.ServerInfo, error) { 43 | var opts []mcpinger.McPingerOption 44 | if t := opt.GetTimeout(); t > 0 { 45 | opts = append(opts, mcpinger.WithTimeout(t)) 46 | } 47 | pinger := mcpinger.New(opt.GetHost(), opt.GetPort(), opts...) 48 | return pinger.Ping() 49 | } 50 | 51 | type specificPromCollector interface { 52 | Collect(metrics chan<- prometheus.Metric) 53 | SetTimeout(t time.Duration) 54 | } 55 | 56 | type promCollectors []specificPromCollector 57 | 58 | func (promCollectors) Describe(descs chan<- *prometheus.Desc) { 59 | descs <- promDescHealthy 60 | descs <- promDescResponseTime 61 | descs <- promDescPlayersOnline 62 | descs <- promDescPlayersMax 63 | } 64 | 65 | func (c promCollectors) Collect(metrics chan<- prometheus.Metric) { 66 | for _, entry := range c { 67 | entry.Collect(metrics) 68 | } 69 | } 70 | 71 | func newPromCollectors(servers []string, bedrockServers []string, logger *zap.Logger) (promCollectors, error) { 72 | var collectors []specificPromCollector 73 | 74 | javaCollectors, err := createPromCollectors(servers, JavaEdition, logger) 75 | if err != nil { 76 | return nil, err 77 | } 78 | collectors = append(collectors, javaCollectors...) 79 | 80 | bedrockCollectors, err := createPromCollectors(bedrockServers, BedrockEdition, logger) 81 | if err != nil { 82 | return nil, err 83 | } 84 | collectors = append(collectors, bedrockCollectors...) 85 | 86 | return collectors, nil 87 | } 88 | 89 | func createPromCollectors(servers []string, edition ServerEdition, logger *zap.Logger) (collectors []specificPromCollector, err error) { 90 | for _, server := range servers { 91 | switch edition { 92 | 93 | case JavaEdition: 94 | host, port, err := SplitHostPort(server, DefaultJavaPort) 95 | if err != nil { 96 | return nil, fmt.Errorf("failed to process server entry '%s': %w", server, err) 97 | } 98 | collectors = append(collectors, newPromJavaCollector(host, port, logger)) 99 | 100 | case BedrockEdition: 101 | host, port, err := SplitHostPort(server, DefaultBedrockPort) 102 | if err != nil { 103 | return nil, fmt.Errorf("failed to process server entry '%s': %w", server, err) 104 | } 105 | collectors = append(collectors, newPromBedrockCollector(host, port, logger)) 106 | } 107 | } 108 | return 109 | } 110 | 111 | func newPromJavaCollector(host string, port uint16, logger *zap.Logger) specificPromCollector { 112 | return &promJavaCollector{ 113 | host: host, 114 | port: port, 115 | logger: logger, 116 | } 117 | } 118 | 119 | type promJavaCollector struct { 120 | host string 121 | port uint16 122 | logger *zap.Logger 123 | timeout time.Duration 124 | } 125 | 126 | func (c *promJavaCollector) GetHost() string { 127 | return c.host 128 | } 129 | 130 | func (c *promJavaCollector) GetPort() uint16 { 131 | return c.port 132 | } 133 | 134 | func (c *promJavaCollector) GetTimeout() time.Duration { 135 | return c.timeout 136 | } 137 | 138 | func (c *promJavaCollector) SetTimeout(t time.Duration) { 139 | c.timeout = t 140 | } 141 | 142 | func (c *promJavaCollector) Collect(metrics chan<- prometheus.Metric) { 143 | c.logger.Debug("pinging", zap.String("host", c.host), zap.String("port", strconv.Itoa(int(c.port)))) 144 | startTime := time.Now() 145 | info, err := pingJavaServer(c) 146 | elapsed := time.Now().Sub(startTime) 147 | 148 | if err != nil { 149 | c.sendMetric(metrics, promDescHealthy, "", 0) 150 | } else { 151 | c.sendMetric(metrics, promDescResponseTime, info.Version.Name, elapsed.Seconds()) 152 | if info.Players.Max == 0 { // when server responds to ping but is not fully ready 153 | c.sendMetric(metrics, promDescHealthy, info.Version.Name, 0) 154 | } else { 155 | c.sendMetric(metrics, promDescHealthy, info.Version.Name, 1) 156 | c.sendMetric(metrics, promDescPlayersOnline, info.Version.Name, float64(info.Players.Online)) 157 | c.sendMetric(metrics, promDescPlayersMax, info.Version.Name, float64(info.Players.Max)) 158 | } 159 | 160 | } 161 | } 162 | 163 | func (c *promJavaCollector) sendMetric(metrics chan<- prometheus.Metric, desc *prometheus.Desc, 164 | version string, value float64) { 165 | 166 | metric, err := prometheus.NewConstMetric(desc, prometheus.GaugeValue, value, 167 | c.host, strconv.Itoa(int(c.port)), string(JavaEdition), version) 168 | if err != nil { 169 | c.logger.Error("failed to build metric", zap.Error(err), zap.String("name", desc.String())) 170 | } else { 171 | metrics <- metric 172 | } 173 | } 174 | 175 | type promBedrockCollector struct { 176 | host string 177 | port uint16 178 | logger *zap.Logger 179 | timeout time.Duration 180 | } 181 | 182 | func (c *promBedrockCollector) GetHost() string { 183 | return c.host 184 | } 185 | 186 | func (c *promBedrockCollector) GetPort() uint16 { 187 | return c.port 188 | } 189 | 190 | func (c *promBedrockCollector) GetTimeout() time.Duration { 191 | return c.timeout 192 | } 193 | 194 | func (c *promBedrockCollector) SetTimeout(t time.Duration) { 195 | c.timeout = t 196 | } 197 | 198 | func newPromBedrockCollector(host string, port uint16, logger *zap.Logger) *promBedrockCollector { 199 | return &promBedrockCollector{host: host, port: port, logger: logger} 200 | } 201 | 202 | func (c *promBedrockCollector) Collect(metrics chan<- prometheus.Metric) { 203 | c.logger.Debug("pinging", zap.String("host", c.host), zap.String("port", strconv.Itoa(int(c.port)))) 204 | 205 | info, err := PingBedrockServer(net.JoinHostPort(c.host, strconv.Itoa(int(c.port))), c.timeout) 206 | if err != nil { 207 | c.sendMetric(metrics, promDescHealthy, "", 0) 208 | } else { 209 | c.sendMetric(metrics, promDescResponseTime, info.Version, info.Rtt.Seconds()) 210 | c.sendMetric(metrics, promDescHealthy, info.Version, 1) 211 | c.sendMetric(metrics, promDescPlayersOnline, info.Version, float64(info.Players)) 212 | c.sendMetric(metrics, promDescPlayersMax, info.Version, float64(info.MaxPlayers)) 213 | } 214 | } 215 | 216 | func (c *promBedrockCollector) sendMetric(metrics chan<- prometheus.Metric, 217 | desc *prometheus.Desc, version string, value float64) { 218 | 219 | metric, err := prometheus.NewConstMetric(desc, prometheus.GaugeValue, value, 220 | c.host, strconv.Itoa(int(c.port)), string(BedrockEdition), version) 221 | if err != nil { 222 | c.logger.Error("failed to build metric", zap.Error(err), zap.String("name", desc.String())) 223 | } else { 224 | metrics <- metric 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /java_status.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "flag" 8 | "fmt" 9 | "github.com/avast/retry-go" 10 | "github.com/itzg/mc-monitor/slp" 11 | "go.uber.org/zap" 12 | "log" 13 | "os" 14 | "time" 15 | 16 | mcpinger "github.com/Raqbit/mc-pinger" 17 | "github.com/google/subcommands" 18 | "github.com/itzg/go-flagsfiller" 19 | "github.com/xrjr/mcutils/pkg/ping" 20 | ) 21 | 22 | type statusCmd struct { 23 | Host string `default:"localhost" usage:"hostname of the Minecraft server" env:"MC_HOST"` 24 | Port int `default:"25565" usage:"port of the Minecraft server" env:"MC_PORT"` 25 | 26 | UseServerListPing bool `usage:"indicates the legacy, server list ping should be used for pre-1.12"` 27 | UseOldServerListPing bool `usage:"indicates older legacy, old server list ping is used for b1.8 to 1.3"` 28 | UseMcUtils bool `usage:"(experimental) try using mcutils to query the server"` 29 | 30 | RetryInterval time.Duration `usage:"if retry-limit is non-zero, status will be retried at this interval" default:"10s"` 31 | RetryLimit int `usage:"if non-zero, failed status will be retried this many times before exiting"` 32 | Timeout time.Duration `usage:"the timeout the ping can take as a maximum" default:"15s"` 33 | 34 | UseProxy bool `usage:"supports contacting Bungeecord when proxy_protocol enabled"` 35 | ProxyVersion byte `usage:"version of PROXY protocol to use" default:"1"` 36 | 37 | SkipReadinessCheck bool `usage:"returns success when pinging a server without player info, or with a max player count of 0"` 38 | 39 | ShowPlayerCount bool `usage:"show just the online player count"` 40 | Json bool `usage:"output server status as JSON"` 41 | } 42 | 43 | func (c *statusCmd) Name() string { 44 | return "status" 45 | } 46 | 47 | func (c *statusCmd) Synopsis() string { 48 | return "Retrieves and displays the status of the given Minecraft server" 49 | } 50 | 51 | func (c *statusCmd) Usage() string { 52 | return "" 53 | } 54 | 55 | func (c *statusCmd) SetFlags(flags *flag.FlagSet) { 56 | filler := flagsfiller.New() 57 | err := filler.Fill(flags, c) 58 | if err != nil { 59 | log.Fatal(err) 60 | } 61 | } 62 | 63 | type statusResult struct { 64 | Host string `json:"host"` 65 | Port int `json:"port"` 66 | ServerInfo *mcpinger.ServerInfo `json:"server_info"` 67 | } 68 | 69 | func (c *statusCmd) Execute(_ context.Context, _ *flag.FlagSet, args ...interface{}) subcommands.ExitStatus { 70 | logger := args[0].(*zap.Logger) 71 | 72 | if c.UseServerListPing { 73 | return c.ExecuteServerListPing() 74 | } 75 | 76 | if c.UseOldServerListPing { 77 | return c.ExecuteOldServerListPing() 78 | } 79 | 80 | if c.UseMcUtils { 81 | return c.ExecuteMcUtilPing(logger) 82 | } 83 | 84 | var options []mcpinger.McPingerOption 85 | if c.Timeout > 0 { 86 | options = append(options, mcpinger.WithTimeout(c.Timeout)) 87 | } 88 | if c.UseProxy { 89 | options = append(options, mcpinger.WithProxyProto(c.ProxyVersion)) 90 | } 91 | 92 | if c.RetryInterval <= 0 { 93 | c.RetryInterval = 1 * time.Second 94 | } 95 | 96 | err := retry.Do(func() error { 97 | logger.Debug("pinging") 98 | pinger := mcpinger.New(c.Host, uint16(c.Port), options...) 99 | info, err := pinger.Ping() 100 | logger.Debug("ping returned", zap.Error(err), zap.Any("info", info)) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | // While server is starting up it will answer pings, but respond with empty JSON object. 106 | // As such, we'll sanity check the max players value to see if a zero-value has been 107 | // provided for info. 108 | if info.Players.Max == 0 && !c.SkipReadinessCheck { 109 | 110 | _, _ = fmt.Fprintf(os.Stderr, "server not ready %s:%d", c.Host, c.Port) 111 | return errors.New("server not ready") 112 | } 113 | 114 | if c.Json { 115 | err := json.NewEncoder(os.Stdout).Encode(statusResult{ 116 | Host: c.Host, 117 | Port: c.Port, 118 | ServerInfo: info, 119 | }) 120 | 121 | if err != nil { 122 | logger.Error("failed to encode info", zap.Error(err)) 123 | } 124 | 125 | } else if c.ShowPlayerCount { 126 | fmt.Printf("%d\n", info.Players.Online) 127 | } else { 128 | fmt.Printf("%s:%d : version=%s online=%d max=%d motd='%s'\n", 129 | c.Host, c.Port, 130 | info.Version.Name, info.Players.Online, info.Players.Max, info.Description.Text) 131 | } 132 | 133 | return nil 134 | 135 | }, 136 | retry.Delay(c.RetryInterval), 137 | retry.DelayType(retry.FixedDelay), 138 | retry.Attempts(uint(c.RetryLimit+1)), 139 | retry.LastErrorOnly(true)) 140 | 141 | if err != nil { 142 | _, _ = fmt.Fprintf(os.Stderr, "failed to ping %s:%d : %s", c.Host, c.Port, err) 143 | return subcommands.ExitFailure 144 | } else { 145 | return subcommands.ExitSuccess 146 | } 147 | } 148 | 149 | func (c *statusCmd) ExecuteServerListPing() subcommands.ExitStatus { 150 | err := retry.Do(func() error { 151 | response, err := slp.ServerListPing(c.Host, c.Port, c.Timeout) 152 | if err != nil { 153 | return err 154 | } 155 | 156 | if response.MaxPlayers == "0" && !c.SkipReadinessCheck { 157 | return errors.New("server not ready") 158 | } 159 | 160 | if c.ShowPlayerCount { 161 | fmt.Printf("%s\n", response.CurrentPlayerCount) 162 | } else { 163 | fmt.Printf("%s:%d : version=%s online=%s max=%s motd='%s'\n", 164 | c.Host, c.Port, 165 | response.ServerVersion, response.CurrentPlayerCount, response.MaxPlayers, response.MessageOfTheDay) 166 | } 167 | 168 | return nil 169 | }, retry.Delay(c.RetryInterval), retry.DelayType(retry.FixedDelay), retry.Attempts(uint(c.RetryLimit+1))) 170 | 171 | if err != nil { 172 | _, _ = fmt.Fprintf(os.Stderr, "failed to ping %s:%d : %s", c.Host, c.Port, err) 173 | return subcommands.ExitFailure 174 | } 175 | 176 | // regular output is within Do function 177 | return subcommands.ExitSuccess 178 | } 179 | 180 | 181 | func (c *statusCmd) ExecuteOldServerListPing() subcommands.ExitStatus { 182 | err := retry.Do(func() error { 183 | response, err := slp.OldServerListPing(c.Host, c.Port, c.Timeout) 184 | if err != nil { 185 | return err 186 | } 187 | 188 | if response.MaxPlayers == "0" && !c.SkipReadinessCheck { 189 | return errors.New("server not ready") 190 | } 191 | 192 | if c.ShowPlayerCount { 193 | fmt.Printf("%s\n", response.CurrentPlayerCount) 194 | } else { 195 | fmt.Printf("%s:%d : online=%s max=%s motd='%s'\n", 196 | c.Host, c.Port, 197 | response.CurrentPlayerCount, response.MaxPlayers, response.MessageOfTheDay) 198 | } 199 | 200 | return nil 201 | }, retry.Delay(c.RetryInterval), retry.DelayType(retry.FixedDelay), retry.Attempts(uint(c.RetryLimit+1))) 202 | 203 | if err != nil { 204 | _, _ = fmt.Fprintf(os.Stderr, "failed to ping %s:%d : %s", c.Host, c.Port, err) 205 | return subcommands.ExitFailure 206 | } 207 | 208 | // regular output is within Do function 209 | return subcommands.ExitSuccess 210 | } 211 | 212 | func (c *statusCmd) ExecuteMcUtilPing(logger *zap.Logger) subcommands.ExitStatus { 213 | client := ping.NewClient(c.Host, c.Port) 214 | client.DialTimeout = c.Timeout 215 | client.ReadTimeout = c.Timeout 216 | 217 | err := retry.Do(func() error { 218 | 219 | err := client.Connect() 220 | if err != nil { 221 | logger.Debug("Client failed to connect", zap.Error(err)) 222 | return err 223 | } 224 | 225 | //goland:noinspection GoUnhandledErrorResult 226 | defer client.Disconnect() 227 | 228 | hs, err := client.Handshake() 229 | if err != nil { 230 | logger.Debug("Client failed to handshake", zap.Error(err)) 231 | return err 232 | } 233 | 234 | response := hs.Properties.Infos() 235 | logger.Debug("mcutils ping returned", zap.Any("properties", response)) 236 | 237 | if response.Players.Max == 0 && !c.SkipReadinessCheck { 238 | return errors.New("server not ready") 239 | } 240 | 241 | if c.ShowPlayerCount { 242 | fmt.Printf("%d\n", response.Players.Online) 243 | } else { 244 | fmt.Printf("%s:%d : version=%s online=%d max=%d motd='%s'\n", 245 | c.Host, c.Port, 246 | response.Version.Name, response.Players.Online, response.Players.Max, response.Description) 247 | } 248 | 249 | return nil 250 | }, retry.Delay(c.RetryInterval), retry.DelayType(retry.FixedDelay), retry.Attempts(uint(c.RetryLimit+1))) 251 | 252 | if err != nil { 253 | _, _ = fmt.Fprintf(os.Stderr, "failed to ping %s:%d : %s", c.Host, c.Port, err) 254 | return subcommands.ExitFailure 255 | } 256 | 257 | // regular output is within Do function 258 | return subcommands.ExitSuccess 259 | } 260 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![Docker Pulls](https://img.shields.io/docker/pulls/itzg/mc-monitor)](https://hub.docker.com/r/itzg/mc-monitor) 3 | [![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/itzg/mc-monitor)](https://github.com/itzg/mc-monitor/releases/latest) 4 | [![Test](https://github.com/itzg/mc-monitor/actions/workflows/test.yml/badge.svg)](https://github.com/itzg/mc-monitor/actions/workflows/test.yml) 5 | 6 | Command/agent to monitor the status of Minecraft servers 7 | 8 | ## Install module 9 | 10 | ``` 11 | go get github.com/itzg/go-mc-status 12 | ``` 13 | 14 | ## Usage 15 | 16 | ``` 17 | Subcommands: 18 | flags describe all known top-level flags 19 | help describe subcommands and their syntax 20 | version Show version and exit 21 | 22 | Subcommands for monitoring: 23 | export-for-prometheus Registers an HTTP metrics endpoints for Prometheus export 24 | gather-for-telegraf Periodically gathers to status of one or more Minecraft servers and sends metrics to telegraf over TCP using Influx line protocol 25 | collect-otel Periodically collects to status of one or more Minecraft servers and sends metrics to an OpenTelemetry Collector using the gRPC protocol 26 | 27 | Subcommands for status: 28 | status Retrieves and displays the status of the given Minecraft server 29 | status-bedrock Retrieves and displays the status of the given Minecraft Bedrock Dedicated server 30 | ``` 31 | 32 | Usage for any of the sub-commands can be displayed by add `--help` after each, such as: 33 | 34 | ```shell 35 | mc-monitor status --help 36 | ``` 37 | 38 | ### status 39 | 40 | ``` 41 | -host string 42 | hostname of the Minecraft server (env MC_HOST) (default "localhost") 43 | -json 44 | output server status as JSON 45 | -port int 46 | port of the Minecraft server (env MC_PORT) (default 25565) 47 | -retry-interval duration 48 | if retry-limit is non-zero, status will be retried at this interval (default 10s) 49 | -retry-limit int 50 | if non-zero, failed status will be retried this many times before exiting 51 | -show-player-count 52 | show just the online player count 53 | -skip-readiness-check 54 | returns success when pinging a server without player info, or with a max player count of 0 55 | -timeout duration 56 | the timeout the ping can take as a maximum (default 15s) 57 | -use-mc-utils 58 | (experimental) try using mcutils to query the server 59 | -use-proxy 60 | supports contacting Bungeecord when proxy_protocol enabled 61 | -use-server-list-ping 62 | indicates the legacy, server list ping should be used for pre-1.12 63 | ``` 64 | 65 | ### status-bedrock 66 | 67 | ``` 68 | -host string 69 | (default "localhost") 70 | -port int 71 | (default 19132) 72 | -retry-interval duration 73 | if retry-limit is non-zero, status will be retried at this interval (default 10s) 74 | -retry-limit int 75 | if non-zero, failed status will be retried this many times before exiting 76 | ``` 77 | 78 | ### export-for-prometheus 79 | 80 | ``` 81 | -bedrock-servers host:port 82 | one or more host:port addresses of Bedrock servers to monitor, when port is omitted 19132 is used (env EXPORT_BEDROCK_SERVERS) 83 | -port int 84 | HTTP port where Prometheus metrics are exported (env EXPORT_PORT) (default 8080) 85 | -servers host:port 86 | one or more host:port addresses of Java servers to monitor, when port is omitted 25565 is used (env EXPORT_SERVERS) 87 | -timeout duration 88 | timeout when checking each servers (env TIMEOUT) (default 1m0s) 89 | ``` 90 | 91 | ### gather-for-telegraf 92 | 93 | ``` 94 | -interval duration 95 | gathers and sends metrics at this interval (env GATHER_INTERVAL) (default 1m0s) 96 | -servers host:port 97 | one or more host:port addresses of servers to monitor (env GATHER_SERVERS) 98 | -telegraf-address host:port 99 | host:port of telegraf accepting Influx line protocol (env GATHER_TELEGRAF_ADDRESS) (default "localhost:8094") 100 | ``` 101 | 102 | ### collect-otel 103 | 104 | ``` 105 | -bedrock-servers host:port 106 | one or more host:port addresses of Bedrock servers to monitor, when port is omitted 19132 is used (env EXPORT_BEDROCK_SERVERS) 107 | -interval duration 108 | Collect and sends OpenTelemetry data at this interval (env EXPORT_INTERVAL) (default 10s) 109 | -otel-collector-endpoint string 110 | OpenTelemetry gRPC endpoint to export data (env EXPORT_OTEL_COLLECTOR_ENDPOINT) (default "localhost:4317") 111 | -otel-collector-timeout duration 112 | Timeout for collecting OpenTelemetry data (env EXPORT_OTEL_COLLECTOR_TIMEOUT) (default 35s) 113 | -servers host:port 114 | one or more host:port addresses of Java servers to monitor, when port is omitted 25565 is used (env EXPORT_SERVERS) 115 | ``` 116 | 117 | ## Examples 118 | 119 | ### Checking the status of a server 120 | 121 | To check the status of a Java edition server: 122 | 123 | ``` 124 | docker run -it --rm itzg/mc-monitor status --host mc.hypixel.net 125 | ``` 126 | 127 | To check the status of a Bedrock Dedicated server: 128 | 129 | ``` 130 | docker run -it --rm itzg/mc-monitor status-bedrock --host play.fallentech.io 131 | ``` 132 | 133 | where exit code will be 0 for success or 1 for failure. 134 | 135 | ### Workarounds for some status errors 136 | 137 | Some Forge servers may cause a `string length out of bounds` error during status messages due to how the [FML2 protocol](https://wiki.vg/Minecraft_Forge_Handshake#FML2_protocol_.281.13_-_Current.29) bundles the entire modlist for client compatibility check. If there are issues with `status` failing when it otherwise should work, you can try out the experimental `--use-mc-utils` flag below (enables the [mcutils](https://github.com/xrjr/mcutils) protocol library): 138 | ``` 139 | docker run -it --rm itzg/mc-monitor status --use-mc-utils --host play.fallentech.io 140 | ``` 141 | 142 | ### Monitoring a server with Telegraf 143 | 144 | > The following example is provided in [examples/mc-monitor-telegraf](examples/mc-monitor-telegraf) 145 | 146 | Given the telegraf config file: 147 | 148 | ```toml 149 | [[inputs.socket_listener]] 150 | service_address = "tcp://:8094" 151 | 152 | [[outputs.file]] 153 | files = ["stdout"] 154 | ``` 155 | 156 | ...and a Docker composition of telegraf and mc-monitor services: 157 | 158 | ```yaml 159 | version: '3' 160 | 161 | services: 162 | telegraf: 163 | image: telegraf:1.13 164 | volumes: 165 | - ./telegraf.conf:/etc/telegraf/telegraf.conf:ro 166 | monitor: 167 | image: itzg/mc-monitor 168 | command: gather-for-telegraf 169 | environment: 170 | GATHER_INTERVAL: 10s 171 | GATHER_TELEGRAF_ADDRESS: telegraf:8094 172 | GATHER_SERVERS: mc.hypixel.net 173 | ``` 174 | 175 | The output of the telegraf service will show metric entries such as: 176 | 177 | ``` 178 | minecraft_status,host=mc.hypixel.net,port=25565,status=success response_time=0.172809649,online=51201i,max=90000i 1576971568953660767 179 | minecraft_status,host=mc.hypixel.net,port=25565,status=success response_time=0.239236074,online=51198i,max=90000i 1576971579020125479 180 | minecraft_status,host=mc.hypixel.net,port=25565,status=success response_time=0.225942383,online=51198i,max=90000i 1576971589006821324 181 | ``` 182 | 183 | ### Monitoring a server with Prometheus 184 | 185 | When using the `export-for-prometheus` subcommand, mc-monitor will serve a Prometheus exporter on port 8080, by default, that collects Minecraft server metrics during each scrape of `/metrics`. 186 | 187 | The sub-command accepts the following arguments, which can also be viewed using `--help`: 188 | ``` 189 | -bedrock-servers host:port 190 | one or more host:port addresses of Bedrock servers to monitor, when port is omitted 19132 is used (env EXPORT_BEDROCK_SERVERS) 191 | -port int 192 | HTTP port where Prometheus metrics are exported (env EXPORT_PORT) (default 8080) 193 | -servers host:port 194 | one or more host:port addresses of Java servers to monitor, when port is omitted 25565 is used (env EXPORT_SERVERS) 195 | ``` 196 | 197 | The following metrics are exported 198 | - `minecraft_status_healthy` 199 | - `minecraft_status_response_time_seconds` 200 | - `minecraft_status_players_online_count` 201 | - `minecraft_status_players_max_count` 202 | 203 | with the labels 204 | - `server_host` 205 | - `server_port` 206 | - `server_edition` : `java` or `bedrock` 207 | - `server_version` 208 | 209 | An example Docker composition is provided in [examples/mc-monitor-prom](examples/mc-monitor-prom), which was used to grab the following screenshot: 210 | 211 | ![Prometheus Chart](docs/prometheus_online_count_chart.png) 212 | 213 | 214 | 215 | ### Monitoring a server with Open Telemetry 216 | 217 | Open Telemetry is a vendor-agnostic way to receive, process and export telemetry data. In this context, monitoring a Minecraft Server with Open Telemetry requires a running [Open Telemetry Collector](https://opentelemetry.io/docs/collector/) to receive the exported data. An example on how to initialize it can be found in [examples/mc-monitor-otel](examples/mc-monitor-otel). 218 | 219 | Once you run the mc-monitor application using the `collect-otel` subcommand, mc-monitor will create the necessary [instrumentation] 220 | (https://opentelemetry.io/docs/languages/go/instrumentation/#metrics) to export the metrics to the collector through the gRPC protocol. 221 | 222 | The Collector will receive and process the data, sending the metrics to any of the supported [backends](https://opentelemetry.io/docs/collector/configuration/#exporters). In our example, you will find the necessary configurations to export metrics through Prometheus. 223 | 224 | The `collect-otel` sub-command accepts the following arguments, which can also be viewed using `--help`: 225 | 226 | ``` 227 | -servers host:port 228 | one or more host:port addresses of Java servers to monitor, when port is omitted 25565 is used (env EXPORT_SERVERS) 229 | -bedrock-servers host:port 230 | one or more host:port addresses of Bedrock servers to monitor, when port is omitted 19132 is used (env EXPORT_BED_ROCK_SERVERS) 231 | -interval duration 232 | Collect and sends OpenTelemetry data at this interval (env EXPORT_INTERVAL) (default 10s) 233 | 234 | -otel-collector-endpoint string 235 | OpenTelemetry gRPC endpoint to export data (env EXPORT_OTEL_COLLECTOR_ENDPOINT) (default "localhost:4317") 236 | -otel-collector-timeout duration 237 | Timeout for collecting OpenTelemetry data (env EXPORT_OTEL_COLLECTOR_TIMEOUT) (default 35s) 238 | ``` 239 | 240 | The following metrics are exported 241 | - `minecraft_status_healthy` 242 | - `minecraft_status_response_time_seconds` 243 | - `minecraft_status_players_online_count` 244 | - `minecraft_status_players_max_count` 245 | 246 | with the labels 247 | - `server_host` 248 | - `server_port` 249 | - `server_edition` : `java` or `bedrock` 250 | - `server_version` 251 | 252 | An example Docker composition is provided in [examples/mc-monitor-otel](examples/mc-monitor-otel). 253 | -------------------------------------------------------------------------------- /examples/mc-monitor-prom/dashboards/mc-monitor.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": "-- Grafana --", 7 | "enable": true, 8 | "hide": true, 9 | "iconColor": "rgba(0, 211, 255, 1)", 10 | "name": "Annotations & Alerts", 11 | "target": { 12 | "limit": 100, 13 | "matchAny": false, 14 | "tags": [], 15 | "type": "dashboard" 16 | }, 17 | "type": "dashboard" 18 | } 19 | ] 20 | }, 21 | "editable": true, 22 | "fiscalYearStartMonth": 0, 23 | "graphTooltip": 0, 24 | "id": 1, 25 | "iteration": 1641155749460, 26 | "links": [], 27 | "liveNow": false, 28 | "panels": [ 29 | { 30 | "datasource": "Prometheus", 31 | "description": "", 32 | "fieldConfig": { 33 | "defaults": { 34 | "color": { 35 | "mode": "thresholds" 36 | }, 37 | "mappings": [ 38 | { 39 | "options": { 40 | "0": { 41 | "color": "red", 42 | "index": 1, 43 | "text": "Down" 44 | }, 45 | "1": { 46 | "color": "green", 47 | "index": 0, 48 | "text": "Up" 49 | } 50 | }, 51 | "type": "value" 52 | } 53 | ], 54 | "max": 1, 55 | "min": 0, 56 | "thresholds": { 57 | "mode": "percentage", 58 | "steps": [ 59 | { 60 | "color": "red", 61 | "value": null 62 | }, 63 | { 64 | "color": "green", 65 | "value": 100 66 | } 67 | ] 68 | }, 69 | "unit": "bool_yes_no" 70 | }, 71 | "overrides": [] 72 | }, 73 | "gridPos": { 74 | "h": 7, 75 | "w": 4, 76 | "x": 0, 77 | "y": 0 78 | }, 79 | "id": 2, 80 | "options": { 81 | "colorMode": "value", 82 | "graphMode": "area", 83 | "justifyMode": "auto", 84 | "orientation": "auto", 85 | "reduceOptions": { 86 | "calcs": [ 87 | "lastNotNull" 88 | ], 89 | "fields": "", 90 | "values": false 91 | }, 92 | "textMode": "auto" 93 | }, 94 | "pluginVersion": "8.3.3", 95 | "targets": [ 96 | { 97 | "datasource": "Prometheus", 98 | "exemplar": true, 99 | "expr": "minecraft_status_healthy{server_host=\"$server\",server_version=~\".+\"}", 100 | "interval": "", 101 | "legendFormat": "{{server_host}}", 102 | "refId": "A" 103 | } 104 | ], 105 | "title": "Healthy", 106 | "type": "stat" 107 | }, 108 | { 109 | "datasource": "Prometheus", 110 | "description": "", 111 | "fieldConfig": { 112 | "defaults": { 113 | "color": { 114 | "mode": "palette-classic" 115 | }, 116 | "custom": { 117 | "axisLabel": "", 118 | "axisPlacement": "auto", 119 | "barAlignment": 0, 120 | "drawStyle": "line", 121 | "fillOpacity": 0, 122 | "gradientMode": "none", 123 | "hideFrom": { 124 | "legend": false, 125 | "tooltip": false, 126 | "viz": false 127 | }, 128 | "lineInterpolation": "linear", 129 | "lineWidth": 1, 130 | "pointSize": 5, 131 | "scaleDistribution": { 132 | "type": "linear" 133 | }, 134 | "showPoints": "never", 135 | "spanNulls": false, 136 | "stacking": { 137 | "group": "A", 138 | "mode": "none" 139 | }, 140 | "thresholdsStyle": { 141 | "mode": "off" 142 | } 143 | }, 144 | "mappings": [], 145 | "thresholds": { 146 | "mode": "absolute", 147 | "steps": [ 148 | { 149 | "color": "green", 150 | "value": null 151 | } 152 | ] 153 | } 154 | }, 155 | "overrides": [ 156 | { 157 | "matcher": { 158 | "id": "byName", 159 | "options": "online" 160 | }, 161 | "properties": [ 162 | { 163 | "id": "custom.fillOpacity", 164 | "value": 100 165 | } 166 | ] 167 | }, 168 | { 169 | "matcher": { 170 | "id": "byName", 171 | "options": "max" 172 | }, 173 | "properties": [ 174 | { 175 | "id": "custom.lineStyle", 176 | "value": { 177 | "dash": [ 178 | 10, 179 | 10 180 | ], 181 | "fill": "dash" 182 | } 183 | } 184 | ] 185 | } 186 | ] 187 | }, 188 | "gridPos": { 189 | "h": 7, 190 | "w": 20, 191 | "x": 4, 192 | "y": 0 193 | }, 194 | "id": 4, 195 | "options": { 196 | "legend": { 197 | "calcs": [], 198 | "displayMode": "list", 199 | "placement": "bottom" 200 | }, 201 | "tooltip": { 202 | "mode": "single" 203 | } 204 | }, 205 | "targets": [ 206 | { 207 | "datasource": "Prometheus", 208 | "exemplar": true, 209 | "expr": "minecraft_status_players_online_count{server_host=\"$server\"}", 210 | "interval": "", 211 | "legendFormat": "online", 212 | "refId": "A" 213 | }, 214 | { 215 | "datasource": "Prometheus", 216 | "exemplar": true, 217 | "expr": "minecraft_status_players_max_count{server_host=\"$server\"}", 218 | "hide": false, 219 | "interval": "", 220 | "legendFormat": "max", 221 | "refId": "B" 222 | } 223 | ], 224 | "title": "Players", 225 | "type": "timeseries" 226 | }, 227 | { 228 | "datasource": "Prometheus", 229 | "fieldConfig": { 230 | "defaults": { 231 | "color": { 232 | "mode": "palette-classic" 233 | }, 234 | "custom": { 235 | "axisLabel": "cores", 236 | "axisPlacement": "auto", 237 | "barAlignment": 0, 238 | "drawStyle": "line", 239 | "fillOpacity": 0, 240 | "gradientMode": "none", 241 | "hideFrom": { 242 | "legend": false, 243 | "tooltip": false, 244 | "viz": false 245 | }, 246 | "lineInterpolation": "linear", 247 | "lineWidth": 1, 248 | "pointSize": 5, 249 | "scaleDistribution": { 250 | "type": "linear" 251 | }, 252 | "showPoints": "auto", 253 | "spanNulls": false, 254 | "stacking": { 255 | "group": "A", 256 | "mode": "none" 257 | }, 258 | "thresholdsStyle": { 259 | "mode": "off" 260 | } 261 | }, 262 | "mappings": [], 263 | "min": 0, 264 | "thresholds": { 265 | "mode": "absolute", 266 | "steps": [ 267 | { 268 | "color": "green", 269 | "value": null 270 | }, 271 | { 272 | "color": "red", 273 | "value": 80 274 | } 275 | ] 276 | } 277 | }, 278 | "overrides": [] 279 | }, 280 | "gridPos": { 281 | "h": 8, 282 | "w": 12, 283 | "x": 0, 284 | "y": 7 285 | }, 286 | "id": 6, 287 | "options": { 288 | "legend": { 289 | "calcs": [], 290 | "displayMode": "hidden", 291 | "placement": "bottom" 292 | }, 293 | "tooltip": { 294 | "mode": "single" 295 | } 296 | }, 297 | "targets": [ 298 | { 299 | "datasource": "Prometheus", 300 | "exemplar": true, 301 | "expr": "rate(container_cpu_user_seconds_total{container_label_com_docker_compose_service=\"$server\"}[$__rate_interval])", 302 | "format": "time_series", 303 | "interval": "", 304 | "legendFormat": "{{container_label_com_docker_compose_service}}", 305 | "refId": "A" 306 | } 307 | ], 308 | "title": "Minecraft Service - CPU Usage", 309 | "type": "timeseries" 310 | }, 311 | { 312 | "datasource": "Prometheus", 313 | "fieldConfig": { 314 | "defaults": { 315 | "color": { 316 | "mode": "palette-classic" 317 | }, 318 | "custom": { 319 | "axisPlacement": "auto", 320 | "barAlignment": 0, 321 | "drawStyle": "line", 322 | "fillOpacity": 0, 323 | "gradientMode": "none", 324 | "hideFrom": { 325 | "legend": false, 326 | "tooltip": false, 327 | "viz": false 328 | }, 329 | "lineInterpolation": "linear", 330 | "lineWidth": 1, 331 | "pointSize": 5, 332 | "scaleDistribution": { 333 | "type": "linear" 334 | }, 335 | "showPoints": "auto", 336 | "spanNulls": false, 337 | "stacking": { 338 | "group": "A", 339 | "mode": "none" 340 | }, 341 | "thresholdsStyle": { 342 | "mode": "off" 343 | } 344 | }, 345 | "mappings": [], 346 | "min": 0, 347 | "thresholds": { 348 | "mode": "absolute", 349 | "steps": [ 350 | { 351 | "color": "green", 352 | "value": null 353 | }, 354 | { 355 | "color": "red", 356 | "value": 80 357 | } 358 | ] 359 | }, 360 | "unit": "bytes" 361 | }, 362 | "overrides": [] 363 | }, 364 | "gridPos": { 365 | "h": 8, 366 | "w": 12, 367 | "x": 12, 368 | "y": 7 369 | }, 370 | "id": 7, 371 | "options": { 372 | "legend": { 373 | "calcs": [], 374 | "displayMode": "hidden", 375 | "placement": "bottom" 376 | }, 377 | "tooltip": { 378 | "mode": "single" 379 | } 380 | }, 381 | "targets": [ 382 | { 383 | "datasource": "Prometheus", 384 | "exemplar": true, 385 | "expr": "container_memory_usage_bytes{container_label_com_docker_compose_service=\"$server\"}", 386 | "format": "time_series", 387 | "interval": "", 388 | "legendFormat": "{{container_label_com_docker_compose_service}}", 389 | "refId": "A" 390 | } 391 | ], 392 | "title": "Minecraft Service - Memory Usage", 393 | "type": "timeseries" 394 | } 395 | ], 396 | "refresh": "1m", 397 | "schemaVersion": 34, 398 | "style": "dark", 399 | "tags": [], 400 | "templating": { 401 | "list": [ 402 | { 403 | "current": { 404 | "selected": false, 405 | "text": "mc", 406 | "value": "mc" 407 | }, 408 | "datasource": "Prometheus", 409 | "definition": "label_values(server_host)", 410 | "hide": 0, 411 | "includeAll": false, 412 | "multi": false, 413 | "name": "server", 414 | "options": [], 415 | "query": { 416 | "query": "label_values(server_host)", 417 | "refId": "StandardVariableQuery" 418 | }, 419 | "refresh": 1, 420 | "regex": "", 421 | "skipUrlSync": false, 422 | "sort": 0, 423 | "type": "query" 424 | } 425 | ] 426 | }, 427 | "time": { 428 | "from": "now-15m", 429 | "to": "now" 430 | }, 431 | "timepicker": {}, 432 | "timezone": "", 433 | "title": "MC Monitor", 434 | "uid": "PpzSgJAnk", 435 | "version": 3, 436 | "weekStart": "" 437 | } -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/Raqbit/mc-pinger v0.2.4 h1:s1iR1qQ/tGSktwPAmn8Lj94pjvn9xreipA++60ksmnw= 3 | github.com/Raqbit/mc-pinger v0.2.4/go.mod h1:AeR7Gd9CW5VbYA5xA9vy0pvbWLOFoV8p8HP5/zpFthQ= 4 | github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= 5 | github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= 6 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 7 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 8 | github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= 9 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 10 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 11 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 12 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 14 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 16 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 17 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 18 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 19 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 20 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 21 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 22 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 23 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 24 | github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 25 | github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= 26 | github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= 27 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 28 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 29 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg= 30 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= 31 | github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= 32 | github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= 33 | github.com/influxdata/line-protocol v0.0.0-20190509173118-5712a8124a9a/go.mod h1:4kt73NQhadE3daL3WhR5EJ/J2ocX0PZzwxQ0gXJ7oFE= 34 | github.com/influxdata/line-protocol v0.0.0-20210311194329-9aa0e372d097 h1:vilfsDSy7TDxedi9gyBkMvAirat/oRcL0lFdJBf6tdM= 35 | github.com/influxdata/line-protocol v0.0.0-20210311194329-9aa0e372d097/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= 36 | github.com/itzg/go-flagsfiller v1.15.0 h1:xspqfbiifTo1qnCpExtfkMN5fSfueB0nMsOsazcTETw= 37 | github.com/itzg/go-flagsfiller v1.15.0/go.mod h1:nR3jrF1gVJ7ZUfSews6/oPbXjBTG3ziIHfLaXstmxjE= 38 | github.com/itzg/line-protocol-sender v0.1.1 h1:UA01VBt3/whRxpwO425w60pdNmgjnGV1tseR4qh6mC0= 39 | github.com/itzg/line-protocol-sender v0.1.1/go.mod h1:Cd948iZ7YibnGcLt5D/11RfKmteh8lQyXpGUbY97WBw= 40 | github.com/itzg/zapconfigs v0.1.0 h1:Gokocm8VaTNnZjvIiVA5NEhzZ1v7lEyXY/AbeBmq6YQ= 41 | github.com/itzg/zapconfigs v0.1.0/go.mod h1:y4dArgRUOFbGRkUNJ8XSSw98FGn03wtkvMPy+OSA5Rc= 42 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 43 | github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= 44 | github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 45 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 46 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 47 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 48 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 49 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 50 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 51 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 52 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 53 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 54 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 55 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 56 | github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs= 57 | github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4= 58 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 59 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 60 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 61 | github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= 62 | github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= 63 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 64 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 65 | github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= 66 | github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= 67 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 68 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 69 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 70 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 71 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 72 | github.com/sandertv/go-raknet v1.14.2 h1:UZLyHn5yQU2Dq2GVq/LlxwAUikaq4q4AA1rl/Pf3AXQ= 73 | github.com/sandertv/go-raknet v1.14.2/go.mod h1:/yysjwfCXm2+2OY8mBazLzcxJ3irnylKCyG3FLgUPVU= 74 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 75 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 76 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 77 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 78 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 79 | github.com/xrjr/mcutils v1.5.1 h1:E4ScafEH+joxWVgRCGpJBJfxWX/vaNJDcoS40V91rVo= 80 | github.com/xrjr/mcutils v1.5.1/go.mod h1:43n8cyMIHYjiRM2LFZLVH5Ppz2+RvWppz6OgkLP8Lsk= 81 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 82 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 83 | go.opentelemetry.io/contrib/instrumentation/runtime v0.59.0 h1:rfi2MMujBc4yowE0iHckZX4o4jg6SA67EnFVL8ldVvU= 84 | go.opentelemetry.io/contrib/instrumentation/runtime v0.59.0/go.mod h1:IO/gfPEcQYpOpPxn1OXFp1DvRY0viP8ONMedXLjjHIU= 85 | go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= 86 | go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= 87 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.34.0 h1:ajl4QczuJVA2TU9W9AGw++86Xga/RKt//16z/yxPgdk= 88 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.34.0/go.mod h1:Vn3/rlOJ3ntf/Q3zAI0V5lDnTbHGaUsNUeF6nZmm7pA= 89 | go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= 90 | go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= 91 | go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= 92 | go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= 93 | go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= 94 | go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= 95 | go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= 96 | go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= 97 | go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= 98 | go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= 99 | go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 100 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 101 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 102 | go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= 103 | go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= 104 | go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 105 | go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 106 | go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= 107 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 108 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 109 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 110 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 111 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 112 | golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 113 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 114 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 115 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 116 | golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= 117 | golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 118 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 119 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 120 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 121 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 122 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 123 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 124 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 125 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 126 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 127 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 128 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 129 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 130 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 131 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 132 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 133 | golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 134 | golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 135 | golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 136 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 137 | google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f h1:gap6+3Gk41EItBuyi4XX/bp4oqJ3UwuIMl25yGinuAA= 138 | google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:Ic02D47M+zbarjYYUlK57y316f2MoN0gjAwI3f2S95o= 139 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= 140 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= 141 | google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= 142 | google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= 143 | google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= 144 | google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 145 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 146 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 147 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 148 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 149 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 150 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 151 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 152 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 153 | honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 154 | -------------------------------------------------------------------------------- /examples/mc-monitor-otel/dashboards/mc-monitor.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": { 7 | "type": "datasource", 8 | "uid": "grafana" 9 | }, 10 | "enable": true, 11 | "hide": true, 12 | "iconColor": "rgba(0, 211, 255, 1)", 13 | "name": "Annotations & Alerts", 14 | "target": { 15 | "limit": 100, 16 | "matchAny": false, 17 | "tags": [], 18 | "type": "dashboard" 19 | }, 20 | "type": "dashboard" 21 | } 22 | ] 23 | }, 24 | "editable": true, 25 | "fiscalYearStartMonth": 0, 26 | "graphTooltip": 0, 27 | "id": 1, 28 | "links": [], 29 | "liveNow": false, 30 | "panels": [ 31 | { 32 | "datasource": { 33 | "type": "prometheus", 34 | "uid": "prometheus" 35 | }, 36 | "description": "", 37 | "fieldConfig": { 38 | "defaults": { 39 | "color": { 40 | "mode": "thresholds" 41 | }, 42 | "mappings": [ 43 | { 44 | "options": { 45 | "0": { 46 | "color": "red", 47 | "index": 1, 48 | "text": "Down" 49 | }, 50 | "1": { 51 | "color": "green", 52 | "index": 0, 53 | "text": "Up" 54 | } 55 | }, 56 | "type": "value" 57 | } 58 | ], 59 | "max": 1, 60 | "min": 0, 61 | "thresholds": { 62 | "mode": "percentage", 63 | "steps": [ 64 | { 65 | "color": "red", 66 | "value": null 67 | }, 68 | { 69 | "color": "green", 70 | "value": 100 71 | } 72 | ] 73 | }, 74 | "unit": "bool_yes_no" 75 | }, 76 | "overrides": [] 77 | }, 78 | "gridPos": { 79 | "h": 7, 80 | "w": 4, 81 | "x": 0, 82 | "y": 0 83 | }, 84 | "id": 2, 85 | "options": { 86 | "colorMode": "value", 87 | "graphMode": "area", 88 | "justifyMode": "auto", 89 | "orientation": "auto", 90 | "percentChangeColorMode": "standard", 91 | "reduceOptions": { 92 | "calcs": [ 93 | "lastNotNull" 94 | ], 95 | "fields": "", 96 | "values": false 97 | }, 98 | "showPercentChange": false, 99 | "textMode": "auto", 100 | "wideLayout": true 101 | }, 102 | "pluginVersion": "11.1.0", 103 | "targets": [ 104 | { 105 | "datasource": { 106 | "type": "prometheus", 107 | "uid": "prometheus" 108 | }, 109 | "exemplar": true, 110 | "expr": "minecraft_status_healthy{server_host=\"$server\",server_version=~\".+\"}", 111 | "interval": "", 112 | "legendFormat": "{{server_host}}", 113 | "refId": "A" 114 | } 115 | ], 116 | "title": "Healthy", 117 | "type": "stat" 118 | }, 119 | { 120 | "datasource": { 121 | "type": "prometheus", 122 | "uid": "prometheus" 123 | }, 124 | "description": "", 125 | "fieldConfig": { 126 | "defaults": { 127 | "color": { 128 | "mode": "palette-classic" 129 | }, 130 | "custom": { 131 | "axisBorderShow": false, 132 | "axisCenteredZero": false, 133 | "axisColorMode": "text", 134 | "axisLabel": "", 135 | "axisPlacement": "auto", 136 | "barAlignment": 0, 137 | "drawStyle": "line", 138 | "fillOpacity": 0, 139 | "gradientMode": "none", 140 | "hideFrom": { 141 | "legend": false, 142 | "tooltip": false, 143 | "viz": false 144 | }, 145 | "insertNulls": false, 146 | "lineInterpolation": "linear", 147 | "lineWidth": 1, 148 | "pointSize": 5, 149 | "scaleDistribution": { 150 | "type": "linear" 151 | }, 152 | "showPoints": "never", 153 | "spanNulls": false, 154 | "stacking": { 155 | "group": "A", 156 | "mode": "none" 157 | }, 158 | "thresholdsStyle": { 159 | "mode": "off" 160 | } 161 | }, 162 | "mappings": [], 163 | "thresholds": { 164 | "mode": "absolute", 165 | "steps": [ 166 | { 167 | "color": "green", 168 | "value": null 169 | } 170 | ] 171 | } 172 | }, 173 | "overrides": [ 174 | { 175 | "matcher": { 176 | "id": "byName", 177 | "options": "online" 178 | }, 179 | "properties": [ 180 | { 181 | "id": "custom.fillOpacity", 182 | "value": 100 183 | } 184 | ] 185 | }, 186 | { 187 | "matcher": { 188 | "id": "byName", 189 | "options": "max" 190 | }, 191 | "properties": [ 192 | { 193 | "id": "custom.lineStyle", 194 | "value": { 195 | "dash": [ 196 | 10, 197 | 10 198 | ], 199 | "fill": "dash" 200 | } 201 | } 202 | ] 203 | } 204 | ] 205 | }, 206 | "gridPos": { 207 | "h": 7, 208 | "w": 11, 209 | "x": 4, 210 | "y": 0 211 | }, 212 | "id": 4, 213 | "options": { 214 | "legend": { 215 | "calcs": [], 216 | "displayMode": "list", 217 | "placement": "bottom", 218 | "showLegend": true 219 | }, 220 | "tooltip": { 221 | "mode": "single", 222 | "sort": "none" 223 | } 224 | }, 225 | "targets": [ 226 | { 227 | "datasource": { 228 | "type": "prometheus", 229 | "uid": "prometheus" 230 | }, 231 | "exemplar": true, 232 | "expr": "minecraft_status_players_online_count{server_host=\"$server\"}", 233 | "interval": "", 234 | "legendFormat": "online", 235 | "refId": "A" 236 | }, 237 | { 238 | "datasource": { 239 | "type": "prometheus", 240 | "uid": "prometheus" 241 | }, 242 | "exemplar": true, 243 | "expr": "minecraft_status_players_max_count{server_host=\"$server\"}", 244 | "hide": false, 245 | "interval": "", 246 | "legendFormat": "max", 247 | "refId": "B" 248 | } 249 | ], 250 | "title": "Players", 251 | "type": "timeseries" 252 | }, 253 | { 254 | "datasource": { 255 | "type": "prometheus", 256 | "uid": "ddvhhy79lfr40e" 257 | }, 258 | "description": "", 259 | "fieldConfig": { 260 | "defaults": { 261 | "color": { 262 | "mode": "palette-classic" 263 | }, 264 | "custom": { 265 | "axisBorderShow": false, 266 | "axisCenteredZero": false, 267 | "axisColorMode": "text", 268 | "axisLabel": "", 269 | "axisPlacement": "auto", 270 | "barAlignment": 0, 271 | "drawStyle": "line", 272 | "fillOpacity": 0, 273 | "gradientMode": "none", 274 | "hideFrom": { 275 | "legend": false, 276 | "tooltip": false, 277 | "viz": false 278 | }, 279 | "insertNulls": false, 280 | "lineInterpolation": "linear", 281 | "lineWidth": 1, 282 | "pointSize": 5, 283 | "scaleDistribution": { 284 | "type": "linear" 285 | }, 286 | "showPoints": "never", 287 | "spanNulls": false, 288 | "stacking": { 289 | "group": "A", 290 | "mode": "none" 291 | }, 292 | "thresholdsStyle": { 293 | "mode": "off" 294 | } 295 | }, 296 | "mappings": [], 297 | "thresholds": { 298 | "mode": "absolute", 299 | "steps": [ 300 | { 301 | "color": "green", 302 | "value": null 303 | } 304 | ] 305 | } 306 | }, 307 | "overrides": [ 308 | { 309 | "matcher": { 310 | "id": "byName", 311 | "options": "response_time" 312 | }, 313 | "properties": [ 314 | { 315 | "id": "custom.fillOpacity", 316 | "value": 100 317 | } 318 | ] 319 | } 320 | ] 321 | }, 322 | "gridPos": { 323 | "h": 7, 324 | "w": 9, 325 | "x": 15, 326 | "y": 0 327 | }, 328 | "id": 8, 329 | "options": { 330 | "legend": { 331 | "calcs": [], 332 | "displayMode": "list", 333 | "placement": "bottom", 334 | "showLegend": true 335 | }, 336 | "tooltip": { 337 | "mode": "single", 338 | "sort": "none" 339 | } 340 | }, 341 | "targets": [ 342 | { 343 | "datasource": { 344 | "type": "prometheus", 345 | "uid": "prometheus" 346 | }, 347 | "editorMode": "code", 348 | "exemplar": true, 349 | "expr": "minecraft_status_response_time{server_host=\"$server\"}", 350 | "interval": "", 351 | "legendFormat": "response_time", 352 | "range": true, 353 | "refId": "A" 354 | } 355 | ], 356 | "title": "Response time", 357 | "type": "timeseries" 358 | }, 359 | { 360 | "datasource": { 361 | "type": "prometheus", 362 | "uid": "prometheus" 363 | }, 364 | "fieldConfig": { 365 | "defaults": { 366 | "color": { 367 | "mode": "palette-classic" 368 | }, 369 | "custom": { 370 | "axisBorderShow": false, 371 | "axisCenteredZero": false, 372 | "axisColorMode": "text", 373 | "axisLabel": "cores", 374 | "axisPlacement": "auto", 375 | "barAlignment": 0, 376 | "drawStyle": "line", 377 | "fillOpacity": 0, 378 | "gradientMode": "none", 379 | "hideFrom": { 380 | "legend": false, 381 | "tooltip": false, 382 | "viz": false 383 | }, 384 | "insertNulls": false, 385 | "lineInterpolation": "linear", 386 | "lineWidth": 1, 387 | "pointSize": 5, 388 | "scaleDistribution": { 389 | "type": "linear" 390 | }, 391 | "showPoints": "auto", 392 | "spanNulls": false, 393 | "stacking": { 394 | "group": "A", 395 | "mode": "none" 396 | }, 397 | "thresholdsStyle": { 398 | "mode": "off" 399 | } 400 | }, 401 | "mappings": [], 402 | "min": 0, 403 | "thresholds": { 404 | "mode": "absolute", 405 | "steps": [ 406 | { 407 | "color": "green", 408 | "value": null 409 | }, 410 | { 411 | "color": "red", 412 | "value": 80 413 | } 414 | ] 415 | } 416 | }, 417 | "overrides": [] 418 | }, 419 | "gridPos": { 420 | "h": 8, 421 | "w": 12, 422 | "x": 0, 423 | "y": 7 424 | }, 425 | "id": 6, 426 | "options": { 427 | "legend": { 428 | "calcs": [], 429 | "displayMode": "list", 430 | "placement": "bottom", 431 | "showLegend": false 432 | }, 433 | "tooltip": { 434 | "mode": "single", 435 | "sort": "none" 436 | } 437 | }, 438 | "targets": [ 439 | { 440 | "datasource": { 441 | "type": "prometheus", 442 | "uid": "prometheus" 443 | }, 444 | "exemplar": true, 445 | "expr": "rate(container_cpu_user_seconds_total{container_label_com_docker_compose_service=\"$server\"}[$__rate_interval])", 446 | "format": "time_series", 447 | "interval": "", 448 | "legendFormat": "{{container_label_com_docker_compose_service}}", 449 | "refId": "A" 450 | } 451 | ], 452 | "title": "Minecraft Service - CPU Usage", 453 | "type": "timeseries" 454 | }, 455 | { 456 | "datasource": { 457 | "type": "prometheus", 458 | "uid": "prometheus" 459 | }, 460 | "fieldConfig": { 461 | "defaults": { 462 | "color": { 463 | "mode": "palette-classic" 464 | }, 465 | "custom": { 466 | "axisBorderShow": false, 467 | "axisCenteredZero": false, 468 | "axisColorMode": "text", 469 | "axisLabel": "", 470 | "axisPlacement": "auto", 471 | "barAlignment": 0, 472 | "drawStyle": "line", 473 | "fillOpacity": 0, 474 | "gradientMode": "none", 475 | "hideFrom": { 476 | "legend": false, 477 | "tooltip": false, 478 | "viz": false 479 | }, 480 | "insertNulls": false, 481 | "lineInterpolation": "linear", 482 | "lineWidth": 1, 483 | "pointSize": 5, 484 | "scaleDistribution": { 485 | "type": "linear" 486 | }, 487 | "showPoints": "auto", 488 | "spanNulls": false, 489 | "stacking": { 490 | "group": "A", 491 | "mode": "none" 492 | }, 493 | "thresholdsStyle": { 494 | "mode": "off" 495 | } 496 | }, 497 | "mappings": [], 498 | "min": 0, 499 | "thresholds": { 500 | "mode": "absolute", 501 | "steps": [ 502 | { 503 | "color": "green", 504 | "value": null 505 | }, 506 | { 507 | "color": "red", 508 | "value": 80 509 | } 510 | ] 511 | }, 512 | "unit": "bytes" 513 | }, 514 | "overrides": [] 515 | }, 516 | "gridPos": { 517 | "h": 8, 518 | "w": 12, 519 | "x": 12, 520 | "y": 7 521 | }, 522 | "id": 7, 523 | "options": { 524 | "legend": { 525 | "calcs": [], 526 | "displayMode": "list", 527 | "placement": "bottom", 528 | "showLegend": false 529 | }, 530 | "tooltip": { 531 | "mode": "single", 532 | "sort": "none" 533 | } 534 | }, 535 | "targets": [ 536 | { 537 | "datasource": { 538 | "type": "prometheus", 539 | "uid": "prometheus" 540 | }, 541 | "exemplar": true, 542 | "expr": "container_memory_usage_bytes{container_label_com_docker_compose_service=\"$server\"}", 543 | "format": "time_series", 544 | "interval": "", 545 | "legendFormat": "{{container_label_com_docker_compose_service}}", 546 | "refId": "A" 547 | } 548 | ], 549 | "title": "Minecraft Service - Memory Usage", 550 | "type": "timeseries" 551 | } 552 | ], 553 | "refresh": "1m", 554 | "schemaVersion": 39, 555 | "tags": [], 556 | "templating": { 557 | "list": [ 558 | { 559 | "current": { 560 | "selected": true, 561 | "text": [ 562 | "localhost" 563 | ], 564 | "value": [ 565 | "localhost" 566 | ] 567 | }, 568 | "datasource": { 569 | "type": "prometheus", 570 | "uid": "prometheus" 571 | }, 572 | "definition": "label_values(server_host)", 573 | "hide": 0, 574 | "includeAll": false, 575 | "label": "server", 576 | "multi": false, 577 | "name": "server", 578 | "options": [], 579 | "query": { 580 | "qryType": 1, 581 | "query": "label_values(server_host)", 582 | "refId": "PrometheusVariableQueryEditor-VariableQuery" 583 | }, 584 | "refresh": 1, 585 | "regex": "", 586 | "skipUrlSync": false, 587 | "sort": 0, 588 | "type": "query" 589 | } 590 | ] 591 | }, 592 | "time": { 593 | "from": "now-15m", 594 | "to": "now" 595 | }, 596 | "timepicker": {}, 597 | "timezone": "", 598 | "title": "MC Monitor", 599 | "uid": "PpzSgJAnk", 600 | "version": 11, 601 | "weekStart": "" 602 | } --------------------------------------------------------------------------------