Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 24 additions & 1 deletion packages/serveradmin/serveradmin/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Copyright (c) 2019 InnoGames GmbH
"""

from time import monotonic

from django.core.exceptions import (
SuspiciousOperation,
PermissionDenied,
Expand All @@ -14,6 +16,8 @@
from adminapi.filters import BaseFilter, FilterValueError
from serveradmin.api import ApiError, AVAILABLE_API_FUNCTIONS
from serveradmin.api.decorators import api_view
from serveradmin.dataset import Query
from serveradmin.querylog.utils import log_query
from serveradmin.serverdb.models import Attribute
from serveradmin.serverdb.query_committer import commit_query
from serveradmin.serverdb.query_executer import execute_query
Expand Down Expand Up @@ -62,9 +66,28 @@ def dataset_query(request, app, data):

order_by = data.get('order_by')

start = monotonic()
result = execute_query(filters, restrict, order_by)
duration_seconds = monotonic() - start

# Query(...) is instantiated here only for its repr(); it is never
# iterated, since that would trigger a second, redundant execution of
# the same query.
log_query(
application=app,
user=app.owner,
source='api',
filters=filters,
restrict=restrict,
order_by=order_by,
duration_seconds=duration_seconds,
query_text=repr(Query(filters, restrict, order_by)),
num_results=len(result),
)

return {
'status': 'success',
'result': execute_query(filters, restrict, order_by),
'result': result,
}


Expand Down
Empty file.
109 changes: 109 additions & 0 deletions packages/serveradmin/serveradmin/querylog/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Serveradmin - Ad-hoc Query Logging
Copyright (c) 2026 InnoGames GmbH
"""

from django import forms
from django.contrib import admin
from django.contrib.admin.widgets import FilteredSelectMultiple

from serveradmin.querylog.models import QueryLog, QueryLoggingRule
from serveradmin.serverdb.models import Attribute


def _attribute_choices():
"""(value, label) choices for the trigger_attributes widget
Computed on demand (called from QueryLoggingRuleForm.__init__, not at
class-definition time) so newly added Attribute rows show up without
restarting the process, and so importing this module never queries
the DB.
"""
real_ids = Attribute.objects.values_list('attribute_id', flat=True)
all_ids = set(real_ids) | set(Attribute.specials.keys())
return sorted((attribute_id, attribute_id) for attribute_id in all_ids)


class QueryLoggingRuleForm(forms.ModelForm):
trigger_attributes = forms.MultipleChoiceField(
required=False,
choices=(),
widget=FilteredSelectMultiple('attributes', is_stacked=False),
)

class Meta:
model = QueryLoggingRule
fields = '__all__'

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['trigger_attributes'].choices = _attribute_choices()


@admin.register(QueryLoggingRule)
class QueryLoggingRuleAdmin(admin.ModelAdmin):
form = QueryLoggingRuleForm
list_display = [
'application', 'user', 'is_active', 'enabled_until',
'short_trigger_query', 'short_trigger_attributes', 'note',
'created_by', 'created_at',
]
list_filter = ['is_active', 'application']
search_fields = ['application__name', 'user__username', 'note']
autocomplete_fields = ['application', 'user']
readonly_fields = ['created_at', 'created_by']
list_select_related = ['application', 'user', 'created_by']

@admin.display(description='Trigger query')
def short_trigger_query(self, obj):
if len(obj.trigger_query) <= 60:
return obj.trigger_query
return obj.trigger_query[:57] + '...'

@admin.display(description='Trigger attributes')
def short_trigger_attributes(self, obj):
joined = ', '.join(obj.trigger_attributes)
if len(joined) <= 60:
return joined
return joined[:57] + '...'

def save_model(self, request, obj, form, change):
if not change:
obj.created_by = request.user
obj.full_clean()
super().save_model(request, obj, form, change)


@admin.register(QueryLog)
class QueryLogAdmin(admin.ModelAdmin):
list_display = [
'created_at', 'source', 'application', 'user',
'duration_ms', 'num_results', 'short_query_text',
]
list_filter = ['source', 'application', 'user']
search_fields = ['query_text']
date_hierarchy = 'created_at'
list_select_related = ['application', 'user', 'rule']

# This table has no automated retention/cleanup (see the plan this
# feature was built from), so it can grow unbounded. Avoid a slow
# exact COUNT(*) on every paginated list view - same motivation as
# the custom NoCountPaginator used for the (much larger) ChangeCommit
# log in serverdb/views.py.
show_full_result_count = False

@admin.display(description='Query')
def short_query_text(self, obj):
if len(obj.query_text) <= 120:
return obj.query_text
return obj.query_text[:117] + '...'

def has_add_permission(self, request):
return False

def has_change_permission(self, request, obj=None):
return False

# has_delete_permission is left at the ModelAdmin default (True):
# deleting old rows via the admin is the only housekeeping mechanism
# since there is no automated retention job in this version.
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Generated by Django 5.2.16 on 2026-08-28 11:35

import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
('apps', '0004_application_last_login'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='QueryLoggingRule',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_active', models.BooleanField(default=True, help_text='Uncheck to disable this rule immediately without losing its configured expiry.')),
('enabled_until', models.DateTimeField(help_text='Logging stops matching automatically after this time.')),
('note', models.TextField(blank=True, help_text='Reason for enabling logging, e.g. a ticket link.')),
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='query_logging_rules', to='apps.application')),
('created_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_query_logging_rules', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='query_logging_rules', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='QueryLog',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('source', models.CharField(choices=[('api', 'API'), ('servershell', 'Servershell')], max_length=16)),
('query_text', models.TextField()),
('filters', models.JSONField(blank=True, null=True)),
('restrict', models.JSONField(blank=True, null=True)),
('order_by', models.JSONField(blank=True, null=True)),
('duration_ms', models.FloatField()),
('num_results', models.IntegerField(blank=True, null=True)),
('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
('application', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='query_logs', to='apps.application')),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='query_logs', to=settings.AUTH_USER_MODEL)),
('rule', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='logs', to='querylog.queryloggingrule')),
],
),
migrations.AddIndex(
model_name='queryloggingrule',
index=models.Index(fields=['is_active', 'enabled_until'], name='querylog_qu_is_acti_acce8b_idx'),
),
migrations.AddConstraint(
model_name='queryloggingrule',
constraint=models.CheckConstraint(condition=models.Q(('application__isnull', False), ('user__isnull', False), _connector='OR'), name='querylog_rule_application_or_user_required'),
),
migrations.AddIndex(
model_name='querylog',
index=models.Index(fields=['source', 'created_at'], name='querylog_qu_source_714e77_idx'),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.16 on 2026-08-28 13:21

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('querylog', '0001_initial'),
]

operations = [
migrations.AddField(
model_name='queryloggingrule',
name='trigger_query',
field=models.CharField(blank=True, help_text='Optional. Only log a query if its filters satisfy this condition, e.g. "hostname=Regexp(\'web.*\') environment=prod". Leave blank to log every query matched by application/user above. Use attr=All() to match any query that references "attr" at all, regardless of its value.', max_length=1000),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.16 on 2026-08-28 13:42

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('querylog', '0002_queryloggingrule_trigger_query'),
]

operations = [
migrations.AddField(
model_name='queryloggingrule',
name='trigger_attributes',
field=models.JSONField(blank=True, default=list, help_text='Optional. Only log a query if its "restrict" (the list of attributes it asked to have returned) includes at least one of the attributes selected here. Leave empty to log every query matched by application/user/trigger_query above. Includes the special attributes object_id, hostname, servertype and intern_ip, which are not real serverdb attributes but are valid values for "restrict".'),
),
]
Empty file.
Loading
Loading