diff --git a/README.md b/README.md index a85e1a5..61376c5 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,9 @@ docker-compose -f docker-compose.cuda.yml up -d --build | --suggestions | Allow user suggestions | `false` | LT_SUGGESTIONS | | --disable-files-translation | Disable files translation | `false` | LT_DISABLE_FILES_TRANSLATION | | --disable-web-ui | Disable web ui | `false` | LT_DISABLE_WEB_UI | -| --update-models | Update language models at startup | `false` | LT_UPDATE_MODELS | +| --update-models | Update language models at startup | `false` | LT_UPDATE_MODELS | +| --metrics | Enable the /metrics endpoint for exporting [Prometheus](https://prometheus.io/) usage metrics | `false` | LT_METRICS | +| --metrics-auth-token | Protect the /metrics endpoint by allowing only clients that have a valid Authorization Bearer token | `No auth` | LT_METRICS_AUTH_TOKEN | Note that each argument has an equivalent environment variable that can be used instead. The env. variables overwrite the default values but have lower priority than the command arguments and are particularly useful if used with Docker. The environment variable names are the upper-snake-case of the equivalent command argument's name with a `LT` prefix. @@ -267,6 +269,47 @@ ltmanage keys remove ltmanage keys ``` +## Prometheus Metrics + +LibreTranslate has Prometheus [exporter](https://prometheus.io/docs/instrumenting/exporters/) capabilities when you pass the `--metrics` argument at startup (disabled by default). When metrics are enabled, a `/metrics` endpoint is mounted on the instance: + +http://localhost:5000/metrics + +``` +# HELP request_inprogress Multiprocess metric +# TYPE request_inprogress gauge +request_inprogress{api_key="",endpoint="/translate",request_ip="127.0.0.1"} 0.0 +# HELP request_seconds Multiprocess metric +# TYPE request_seconds summary +request_seconds_count{api_key="",endpoint="/translate",request_ip="127.0.0.1",status="200"} 0.0 +request_seconds_sum{api_key="",endpoint="/translate",request_ip="127.0.0.1",status="200"} 0.0 +``` + +You can then configure `prometheus.yml` to read the metrics: + +``` +scrape_configs: + - job_name: "libretranslate" + + # Needed only if you use --metrics-auth-token + #authorization: + #credentials: "mytoken" + + static_configs: + - targets: ["localhost:5000"] +``` + +To secure the `/metrics` endpoint you can also use `--metrics-auth-token mytoken`. + +If you use Gunicorn, make sure to create a directory for storing multiprocess data metrics and set `PROMETHEUS_MULTIPROC_DIR`: + +``` +mkdir -p /tmp/prometheus_data +rm /tmp/prometheus_data/* +export PROMETHEUS_MULTIPROC_DIR=/tmp/prometheus_data +gunicorn -c gunicorn_conf.py --bind 0.0.0.0:5000 'wsgi:app(metrics=True)' +``` + ## Language Bindings You can use the LibreTranslate API using the following bindings: diff --git a/VERSION b/VERSION index d0149fe..80e78df 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.4 +1.3.5 diff --git a/app/app.py b/app/app.py index f4c5019..09920f1 100644 --- a/app/app.py +++ b/app/app.py @@ -4,15 +4,17 @@ import tempfile import uuid from functools import wraps from html import unescape +from timeit import default_timer import argostranslatefiles from argostranslatefiles import get_supported_formats from flask import (Flask, abort, jsonify, render_template, request, send_file, - url_for) + url_for, Response) from flask_swagger import swagger from flask_swagger_ui import get_swaggerui_blueprint from translatehtml import translate_html from werkzeug.utils import secure_filename +from werkzeug.exceptions import HTTPException from app import flood, remove_translated_files, security from app.language import detect_languages, improve_translation_formatting @@ -174,6 +176,28 @@ def create_app(args): if args.req_flood_threshold > 0: flood.setup(args.req_flood_threshold) + measure_request = None + gauge_request = None + if args.metrics: + from prometheus_client import CONTENT_TYPE_LATEST, Summary, Gauge, CollectorRegistry, multiprocess, generate_latest + + @app.route("/metrics") + def prometheus_metrics(): + if args.metrics_auth_token: + authorization = request.headers.get('Authorization') + if authorization != "Bearer " + args.metrics_auth_token: + abort(401, description="Unauthorized") + + registry = CollectorRegistry() + multiprocess.MultiProcessCollector(registry) + return Response(generate_latest(registry), mimetype=CONTENT_TYPE_LATEST) + + measure_request = Summary('request_seconds', 'Time spent on request', ['endpoint', 'status', 'request_ip', 'api_key']) + measure_request.labels('/translate', 200, '127.0.0.1', '') + + gauge_request = Gauge('request_inprogress', 'Active requests', ['endpoint', 'request_ip', 'api_key'], multiprocess_mode='livesum') + gauge_request.labels('/translate', '127.0.0.1', '') + def access_check(f): @wraps(f) def func(*a, **kw): @@ -203,11 +227,30 @@ def create_app(args): 403, description=description, ) - return f(*a, **kw) - - return func - + + if args.metrics: + @wraps(func) + def measure_func(*a, **kw): + start_t = default_timer() + status = 200 + ip = get_remote_address() + ak = get_req_api_key() or '' + g = gauge_request.labels(request.path, ip, ak) + try: + g.inc() + return func(*a, **kw) + except HTTPException as e: + status = e.code + raise e + finally: + duration = max(default_timer() - start_t, 0) + measure_request.labels(request.path, status, ip, ak).observe(duration) + g.dec() + return measure_func + else: + return func + @app.errorhandler(400) def invalid_api(e): return jsonify({"error": str(e.description)}), 400 diff --git a/app/default_values.py b/app/default_values.py index 2ee5869..a36f966 100644 --- a/app/default_values.py +++ b/app/default_values.py @@ -160,6 +160,16 @@ _default_options_objects = [ 'name': 'UPDATE_MODELS', 'default_value': False, 'value_type': 'bool' + }, + { + 'name': 'METRICS', + 'default_value': False, + 'value_type': 'bool' + }, + { + 'name': 'METRICS_AUTH_TOKEN', + 'default_value': '', + 'value_type': 'str' }, ] diff --git a/app/main.py b/app/main.py index 52aa06e..b4e1748 100644 --- a/app/main.py +++ b/app/main.py @@ -147,6 +147,18 @@ def get_args(): parser.add_argument( "--update-models", default=DEFARGS['UPDATE_MODELS'], action="store_true", help="Update language models at startup" ) + parser.add_argument( + "--metrics", + default=DEFARGS['METRICS'], + action="store_true", + help="Enable the /metrics endpoint for exporting Prometheus usage metrics", + ) + parser.add_argument( + "--metrics-auth-token", + default=DEFARGS['METRICS_AUTH_TOKEN'], + type=str, + help="Protect the /metrics endpoint by allowing only clients that have a valid Authorization Bearer token (%(default)s)", + ) return parser.parse_args() diff --git a/gunicorn_conf.py b/gunicorn_conf.py new file mode 100644 index 0000000..6ec7e40 --- /dev/null +++ b/gunicorn_conf.py @@ -0,0 +1,4 @@ +from prometheus_client import multiprocess + +def child_exit(server, worker): + multiprocess.mark_process_dead(worker.pid) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 062976f..1ac09fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,4 @@ itsdangerous==2.1.2 Werkzeug==2.2.2 requests==2.28.1 redis==4.3.4 +prometheus-client==0.15.0 \ No newline at end of file