Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
.venv/
.env
.idea/
*.pyc
*.pyc
.coverage
common.sqlite3
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
BSD 3-Clause License

Copyright (c) 2024, Flagsmith
Copyright (c) 2025, Flagsmith

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
Expand Down
50 changes: 48 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# flagsmith-common
A repository for including code that is required in multiple flagsmith repositories
Flagsmith's common library

### Development Setup

This project uses [Poetry](https://python-poetry.org/) for dependency management and includes a Makefile to simplify common development tasks.

#### Prerequisites

- Python >= 3.8
- Python >= 3.11
- Make

#### Installation
Expand Down Expand Up @@ -47,3 +47,49 @@ make install-packages opts="--with dev"
# Install with specific extras
make install-packages opts="--extras 'feature1 feature2'"
```

### Usage

#### Installation

1. Make sure `"common.core"` is in the `INSTALLED_APPS` of your settings module.
This enables the `manage.py flagsmith` commands.

2. Add `"common.gunicorn.middleware.RouteLoggerMiddleware"` to `MIDDLEWARE` in your settings module.
This enables the `route` label for Prometheus HTTP metrics.

3. To enable the `/metrics` endpoint, set the `PROMETHEUS_ENABLED` setting to `True`.

#### Metrics

Flagsmith uses Prometheus to track performance metrics.

The following default metrics are exposed:

- `flagsmith_build_info`: Has the labels `version` and `ci_commit_sha`.
- `http_server_request_duration_seconds`: Histogram labeled with `method`, `path`, and `response_status`.
- `http_server_requests_total`: Counter labeled with `method`, `path`, and `response_status`.

##### Guidelines

Try to come up with meaningful metrics to cover your feature with when developing it. Refer to [Prometheus best practices][1] when naming your metric and labels.

Define your metrics in a `metrics.py` module of your Django application — see [example][2]. Contrary to Prometheus Python client examples and documentation, please name a metric variable exactly as your metric name.

It's generally a good idea to allow users to define histogram buckets of their own. Flagsmith accepts a `PROMETHEUS_HISTOGRAM_BUCKETS` setting so users can customise their buckets. To honour the setting, use the `common.prometheus.Histogram` class when defining your histograms. When using `prometheus_client.Histogram` directly, please expose a dedicated setting like so:

```python
import prometheus_client
from django.conf import settings

distance_from_earth_au = prometheus.Histogram(
"distance_from_earth_au",
"Distance from Earth in astronomical units",
buckets=settings.DISTANCE_FROM_EARTH_AU_HISTOGRAM_BUCKETS,
)
```

[1]: https://prometheus.io/docs/practices/naming/
[2]: https://github.com/Flagsmith/flagsmith-common/blob/main/src/common/gunicorn/metrics.py
[3]: https://docs.gunicorn.org/en/stable/design.html#server-model
[4]: https://prometheus.github.io/client_python/multiprocess
362 changes: 300 additions & 62 deletions poetry.lock

Large diffs are not rendered by default.

77 changes: 59 additions & 18 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,29 +1,70 @@
[tool.poetry]
name = "flagsmith_common"
[project]
name = "flagsmith-common"
version = "1.5.0"
description = "A repository for including code that is required in multiple flagsmith repositories"
authors = ["Matthew Elwell <[email protected]>"]
description = "Flagsmith's common library"
requires-python = ">=3.11,<4.0"
dependencies = [
"django (<5)",
"django-health-check",
"djangorestframework-recursive",
"djangorestframework",
"drf-writable-nested",
"flagsmith-flag-engine",
"gunicorn (>=19.1)",
"prometheus-client (>=0.0.16)",
"environs (<15)",
]
authors = [
{ name = "Matthew Elwell" },
{ name = "Gagan Trivedi" },
{ name = "Kim Gustyr" },
{ name = "Zach Aysan" },
{ name = "Francesco Lo Franco" },
{ name = "Rodrigo López Dato" },
]
maintainers = [{ name = "Flagsmith Team", email = "[email protected]" }]
license = "BSD-3-Clause"
license-files = ["LICENSE"]
readme = "README.md"
packages = [{ include = "common", from = "src" }]
dynamic = ["classifiers"]

[project.urls]
Download = "https://github.com/flagsmith/flagsmith-common/releases"
Homepage = "https://flagsmith.com"
Issues = "https://github.com/flagsmith/flagsmith-common/issues"
Repository = "https://github.com/flagsmith/flagsmith-common"

[tool.poetry.dependencies]
python = "^3.10"
django = "<5.0.0"
djangorestframework = "*"
drf-writable-nested = "*"
flagsmith-flag-engine = "*"
djangorestframework-recursive = "*"
django-health-check = "^3.18.3"
[project.scripts]
flagsmith = "common.core.main:main"

[project.entry-points.pytest11]
flagsmith-test-tools = "common.test_tools.plugin"

[tool.poetry]
requires-poetry = ">=2.0"
classifiers = [
"Framework :: Django",
"Framework :: Pytest",
"Intended Audience :: Developers",
"License :: OSI Approved :: BSD License",
]
packages = [{ include = "common", from = "src" }]

[tool.poetry.group.dev.dependencies]
django-stubs = "^5.1.3"
djangorestframework-stubs = "^3.15.3"
mypy = "^1.15.0"
pre-commit = "*"
ruff = "*"
pytest = "^8.3.4"
pyfakefs = "^5.7.4"
pytest = "^8.3.4"
pytest-asyncio = "^0.25.3"
pytest-cov = "^6.0.0"
pytest-django = "^4.10.0"
mypy = "^1.15.0"
django-stubs = "^5.1.3"
djangorestframework-stubs = "^3.15.3"
pytest-freezegun = "^0.4.2"
pytest-mock = "^3.14.0"
requests = "^2.32.3"
ruff = "*"
setuptools = "^77.0.3"

[build-system]
requires = ["poetry-core"]
Expand Down
24 changes: 18 additions & 6 deletions settings/dev.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import prometheus_client

# Settings expected by `mypy_django_plugin`
SENDGRID_API_KEY: str
AWS_SES_REGION_ENDPOINT: str
SEGMENT_RULES_CONDITIONS_LIMIT: int
SENDGRID_API_KEY: str
PROMETHEUS_ENABLED = True

# Settings required for tests
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "common",
"NAME": "common.sqlite3",
}
}
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"common.core",
]
MIDDLEWARE = [
"common.gunicorn.middleware.RouteLoggerMiddleware",
]
LOG_FORMAT = "json"
PROMETHEUS_HISTOGRAM_BUCKETS = prometheus_client.Histogram.DEFAULT_BUCKETS
ROOT_URLCONF = "common.core.urls"
TIME_ZONE = "UTC"
USE_TZ = True
12 changes: 0 additions & 12 deletions src/common/app/views.py

This file was deleted.

File renamed without changes.
6 changes: 6 additions & 0 deletions src/common/core/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class CoreConfig(AppConfig):
name = "common.core"
label = "common_core"
24 changes: 24 additions & 0 deletions src/common/core/logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import json
import logging
from typing import Any


class JsonFormatter(logging.Formatter):
"""Custom formatter for json logs."""

def get_json_record(self, record: logging.LogRecord) -> dict[str, Any]:
formatted_message = record.getMessage()
json_record = {
"levelname": record.levelname,
"message": formatted_message,
"timestamp": self.formatTime(record, self.datefmt),
"logger_name": record.name,
"pid": record.process,
"thread_name": record.threadName,
}
if record.exc_info:
json_record["exc_info"] = self.formatException(record.exc_info)
return json_record

def format(self, record: logging.LogRecord) -> str:
return json.dumps(self.get_json_record(record))
40 changes: 40 additions & 0 deletions src/common/core/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import logging
import os
import sys
import tempfile

from django.core.management import execute_from_command_line

logger = logging.getLogger(__name__)


def main() -> None:
"""
The main entry point to the Flagsmith application.

An equivalent to Django's `manage.py` script, this module is used to run management commands.

It's installed as the `flagsmith` command.

Everything that needs to be run before Django is started should be done here.

The end goal is to eventually replace Core API's `run-docker.sh` with this.

Usage:
`flagsmith <command> [options]`
"""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.dev")

# Set up Prometheus' multiprocess mode
if "PROMETHEUS_MULTIPROC_DIR" not in os.environ:
prometheus_multiproc_dir = tempfile.TemporaryDirectory(
prefix="prometheus_multiproc",
)
logger.info(
"Created %s for Prometheus multi-process mode",
prometheus_multiproc_dir.name,
)
os.environ["PROMETHEUS_MULTIPROC_DIR"] = prometheus_multiproc_dir.name

# Run Django
execute_from_command_line(sys.argv)
Empty file.
Empty file.
41 changes: 41 additions & 0 deletions src/common/core/management/commands/start.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from typing import Any, Callable

from django.core.management import BaseCommand, CommandParser
from django.utils.module_loading import autodiscover_modules

from common.gunicorn.utils import add_arguments, run_server


class Command(BaseCommand):
help = "Start the Flagsmith application."

def create_parser(self, *args: Any, **kwargs: Any) -> CommandParser:
return super().create_parser(*args, conflict_handler="resolve", **kwargs)

def add_arguments(self, parser: CommandParser) -> None:
add_arguments(parser)

subparsers = parser.add_subparsers(
title="sub-commands",
required=True,
)
api_parser = subparsers.add_parser(
"api",
help="Start the Core API.",
)
api_parser.set_defaults(handle_method=self.handle_api)

def initialise(self) -> None:
autodiscover_modules("metrics")

def handle(
self,
*args: Any,
handle_method: Callable[..., None],
**options: Any,
) -> None:
self.initialise()
handle_method(*args, **options)

def handle_api(self, *args: Any, **options: Any) -> None:
run_server(options)
23 changes: 23 additions & 0 deletions src/common/core/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import prometheus_client

from common.core.utils import get_version_info

flagsmith_build_info = prometheus_client.Gauge(
"flagsmith_build_info",
"Flagsmith version and build information",
["ci_commit_sha", "version"],
multiprocess_mode="livemax",
)


def advertise() -> None:
# Advertise Flagsmith build info.
version_info = get_version_info()

flagsmith_build_info.labels(
ci_commit_sha=version_info["ci_commit_sha"],
version=version_info.get("package_versions", {}).get(".") or "unknown",
).set(1)


advertise()
6 changes: 5 additions & 1 deletion src/common/app/urls.py → src/common/core/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.conf import settings
from django.urls import include, re_path

from common.app import views
from common.core import views

urlpatterns = [
re_path(r"^version/?", views.version_info),
Expand All @@ -11,3 +12,6 @@
# see https://www.aptible.com/docs/core-concepts/apps/connecting-to-apps/app-endpoints/https-endpoints/health-checks
re_path(r"^healthcheck", include("health_check.urls", namespace="health-aptible")),
]

if settings.PROMETHEUS_ENABLED:
urlpatterns += [re_path(r"^metrics/?", views.metrics)]
File renamed without changes.
23 changes: 23 additions & 0 deletions src/common/core/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import logging

import prometheus_client
from django.http import HttpResponse, JsonResponse
from rest_framework.request import Request

from common.core import utils
from common.prometheus.utils import get_registry

logger = logging.getLogger(__name__)


def version_info(request: Request) -> JsonResponse:
return JsonResponse(utils.get_version_info())


def metrics(request: Request) -> HttpResponse:
registry = get_registry()
metrics_page = prometheus_client.generate_latest(registry)
return HttpResponse(
metrics_page,
content_type=prometheus_client.CONTENT_TYPE_LATEST,
)
Empty file.
Loading