Модуль 3 · Форма данных и скорость

Справочник по MongoDB · модуль 3 · форма данных и скорость

3.6

3.6

Скрипт целиком:
аудит журнала сервиса

В этой главе разобрана готовая программа, которая собирает приёмы модуля 3 в одну задачу. Скрипт копирует журнал сервиса в песочницу, проверяет события правилом и включает его как валидатор, снимает планы трёх запросов дежурного, строит под них индексы и печатает, сколько сервер читал до индексов и сколько после. Скрипт разобран по частям, затем приведён целиком вместе с выводом.

Категория
Форма данных
Уровень
Начальный
Связанные темы
Правила $jsonSchema (глава 3.1), индексы (глава 3.2), план запроса и правило ESR (глава 3.3)
Зачем это знать
Видеть, как правило, индексы и проверка плана складываются в одну рабочую процедуру

Как запускать примеры этой главы

БазыСкрипт пересоздаёт коллекцию sandbox.audit при каждом запуске. Сбрасывать базы перед запуском не нужно.
Архивmongodb-glava-3.6-python.zip, 58 КБmongodb-glava-3.6-ruby.zip, 57 КБmongodb-glava-3.6-go.zip, 61 КБmongodb-glava-3.6-cpp.zip, 59 КБ — все примеры главы готовыми программами, базы и стенд в Docker. В README.md — запуск в Docker, если Compass нет или он не подключается, и на установленном сервере с Compass, а также что должна напечатать каждая программа.
Файлscripts/logs_audit.pyscripts/logs_audit.rbscripts/logs_audit.goscripts/logs_audit.cpp в репозитории практик — готовая программа, вставлять в неё ничего не нужно.
ЗапускИз корня репозитория практик: docker compose run --rm python scripts/logs_audit.pydocker compose run --rm ruby scripts/logs_audit.rbdocker compose run --rm go scripts/logs_audit.godocker compose run --rm cpp scripts/logs_audit.cpp. Запуск на своём сервере — в §10.

§1Что делает скрипт

Скрипт logs_audit решает задачу администратора базы: журнал событий сервиса растёт, дежурные жалуются на медленные запросы, а в журнал иногда попадают события с неверными полями. Нужно описать, каким должно быть событие, запретить запись неверных, выяснить, как сервер выполняет запросы дежурных, и ускорить их индексами. Результат проверяется числами из отчёта сервера.

Семь разделов отчёта — приёмы трёх глав модуля.

Раздел отчётаЧто используетсяГлава
1. Копия журналакопия коллекции в песочнице, список индексов3.2
2. Правило для событий$jsonSchema в фильтре, $nor, collMod3.1
3. Неверные событияотказ с кодом 121, причины из errInfo3.1
4. Запросы без индексовexplain, стадии плана, три счётчика3.3
5. Индексысоставные индексы по правилу ESR3.2, 3.3
6. Запросы с индексамитот же отчёт explain для сравнения3.3
7. Последние ошибки платежейзапрос с сортировкой и пределом1.6, 2.2

Скрипт работает с копией журнала — коллекцией sandbox.audit — и пересоздаёт её при каждом запуске. База logs остаётся без изменений, сбрасывать базы перед запуском не нужно.

§2Подключение и функция summary

Начало скрипта — подключение, коллекция журнала events из базы logs, база sandbox и коллекция audit в ней. Функция title печатает заголовок раздела отчёта с подчёркиванием. Функция summary — та же, что в заготовке главы 3.3: она отправляет команду explain для запроса и возвращает стадии выбранного плана и три счётчика. Здесь она возвращает четыре значения по отдельности, чтобы отчёт напечатал их столбцами.

В Go над main объявлены также запросы дежурного (queries), функция report и тип schemaRule для разбора errInfo: функции и типы в Go объявляются вне main. Они разобраны в §4 и §5.

В C++ над main объявлены также запросы дежурного (функция queries), функция report и вспомогательные pad, right и number: функции в C++ объявляются вне main. Запросы и report разобраны в §5.

Python · PyMongoподключение и функция summary
import os
from datetime import datetime

from pymongo import MongoClient
from pymongo.errors import WriteError

client = MongoClient(os.environ.get("MONGO_URI", "mongodb://localhost:27017/"))
events = client["logs"]["events"]
sandbox = client["sandbox"]
audit = sandbox["audit"]


def title(text):
    print("\n" + text)
    print("─" * len(text))


def summary(find):
    """План и счётчики explain: стадии сверху вниз, вернул, ключей, документов."""
    info = sandbox.command("explain", {"find": "audit", **find}, verbosity="executionStats")
    stage = info["queryPlanner"]["winningPlan"]
    stage = stage.get("queryPlan", stage)
    steps = []
    while stage:
        steps.append(stage["stage"] + (" " + stage["indexName"] if "indexName" in stage else ""))
        stage = stage.get("inputStage")
    stats = info["executionStats"]
    return " → ".join(steps), stats["nReturned"], stats["totalKeysExamined"], stats["totalDocsExamined"]
C++ · mongocxxподключение и функция summary
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <ctime>
#include <iomanip>
#include <iostream>
#include <sstream>
#include <string>
#include <tuple>
#include <vector>

#include <bsoncxx/builder/basic/array.hpp>
#include <bsoncxx/builder/basic/document.hpp>
#include <bsoncxx/builder/basic/kvp.hpp>
#include <bsoncxx/json.hpp>
#include <bsoncxx/types.hpp>
#include <mongocxx/client.hpp>
#include <mongocxx/collection.hpp>
#include <mongocxx/exception/operation_exception.hpp>
#include <mongocxx/instance.hpp>
#include <mongocxx/options/find.hpp>
#include <mongocxx/uri.hpp>

using bsoncxx::builder::basic::kvp;
using bsoncxx::builder::basic::make_array;
using bsoncxx::builder::basic::make_document;

int simvolov(const std::string& text) {
    int n = 0;
    for (unsigned char c : text)
        if ((c & 0xC0) != 0x80) ++n;
    return n;
}

std::string pad(const std::string& text, int width) {
    int add = width - simvolov(text);
    return add > 0 ? text + std::string(add, ' ') : text;
}

std::string right(const std::string& text, int width) {
    int add = width - simvolov(text);
    return add > 0 ? std::string(add, ' ') + text : text;
}

void title(const std::string& text) {
    std::cout << "\n" << text << "\n";
    for (int i = 0; i < simvolov(text); ++i) std::cout << "─";
    std::cout << std::endl;
}

// Число из ответа сервера: счётчики приходят как int32 или int64.
std::int64_t number(bsoncxx::document::element value) {
    return value.type() == bsoncxx::type::k_int64 ? value.get_int64().value : value.get_int32().value;
}

// План и счётчики explain: стадии сверху вниз, вернул, ключей, документов.
std::tuple<std::string, std::int64_t, std::int64_t, std::int64_t> summary(mongocxx::database& sandbox,
                                                                          bsoncxx::document::view find) {
    bsoncxx::builder::basic::document cmd;
    cmd.append(kvp("find", "audit"));
    for (auto&& field : find) cmd.append(kvp(field.key(), field.get_value()));
    auto info = sandbox.run_command(make_document(kvp("explain", cmd.view()), kvp("verbosity", "executionStats")));
    auto stage = info.view()["queryPlanner"]["winningPlan"].get_document().value;
    if (stage["queryPlan"]) stage = stage["queryPlan"].get_document().value;
    std::string steps;
    while (true) {
        steps += std::string{stage["stage"].get_string().value};
        if (stage["indexName"]) steps += " " + std::string{stage["indexName"].get_string().value};
        if (!stage["inputStage"]) break;
        steps += " → ";
        stage = stage["inputStage"].get_document().value;
    }
    auto stats = info.view()["executionStats"].get_document().value;
    return {steps, number(stats["nReturned"]), number(stats["totalKeysExamined"]), number(stats["totalDocsExamined"])};
}

// Запросы дежурного: название и части команды find.
std::vector<std::pair<std::string, bsoncxx::document::value>> queries() {
    std::vector<std::pair<std::string, bsoncxx::document::value>> out;
    out.emplace_back("ошибки платежей", make_document(
        kvp("filter", make_document(kvp("service", "payments"), kvp("status", make_document(kvp("$gte", 500))))),
        kvp("sort", make_document(kvp("ts", -1))),
        kvp("limit", 5)));
    out.emplace_back("медленный поиск", make_document(
        kvp("filter", make_document(kvp("service", "search"), kvp("duration_ms", make_document(kvp("$gte", 1000))))),
        kvp("sort", make_document(kvp("duration_ms", -1))),
        kvp("limit", 5)));
    out.emplace_back("события покупателя", make_document(
        kvp("filter", make_document(kvp("user_id", "c-015"))),
        kvp("sort", make_document(kvp("ts", -1)))));
    return out;
}

void report(mongocxx::database& sandbox) {
    std::cout << "  " << pad("запрос", 20) << " " << right("вернул", 6) << " " << right("ключей", 7) << " "
              << right("документов", 11) << std::endl;
    for (const auto& [name, find] : queries()) {
        auto [plan, returned, keys, docs] = summary(sandbox, find.view());
        std::cout << "  " << pad(name, 20) << " " << std::setw(6) << returned << " " << std::setw(7) << keys << " "
                  << std::setw(11) << docs << std::endl;
        std::cout << "      " << plan << std::endl;
    }
}

int main() {
    mongocxx::instance instance{};
    const char* env = std::getenv("MONGO_URI");
    mongocxx::client client{mongocxx::uri{env ? env : "mongodb://localhost:27017"}};

    auto events = client["logs"]["events"];
    auto sandbox = client["sandbox"];
    auto audit = sandbox["audit"];
Go · mongo-driverподключение и функция summary
package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"os"
	"strings"
	"time"
	"unicode/utf8"

	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

func pad(text string, width int) string {
	if n := utf8.RuneCountInString(text); n < width {
		return text + strings.Repeat(" ", width-n)
	}
	return text
}

func title(text string) {
	fmt.Println("\n" + text)
	fmt.Println(strings.Repeat("─", utf8.RuneCountInString(text)))
}

// summary — план и счётчики explain: стадии сверху вниз, вернул, ключей, документов.
func summary(sandbox *mongo.Database, find bson.D) (string, int64, int64, int64) {
	cmd := append(bson.D{{Key: "find", Value: "audit"}}, find...)
	var info bson.Raw
	sandbox.RunCommand(context.Background(),
		bson.D{{Key: "explain", Value: cmd}, {Key: "verbosity", Value: "executionStats"}}).Decode(&info)
	stage := info.Lookup("queryPlanner", "winningPlan").Document()
	if inner, ok := stage.Lookup("queryPlan").DocumentOK(); ok {
		stage = inner
	}
	steps := []string{}
	for {
		step := stage.Lookup("stage").StringValue()
		if name, ok := stage.Lookup("indexName").StringValueOK(); ok {
			step += " " + name
		}
		steps = append(steps, step)
		next, ok := stage.Lookup("inputStage").DocumentOK()
		if !ok {
			break
		}
		stage = next
	}
	stats := info.Lookup("executionStats").Document()
	return strings.Join(steps, " → "), stats.Lookup("nReturned").AsInt64(),
		stats.Lookup("totalKeysExamined").AsInt64(), stats.Lookup("totalDocsExamined").AsInt64()
}

// Запрос дежурного: название и части команды find.
type query struct {
	name string
	find bson.D
}

var queries = []query{
	{"ошибки платежей", bson.D{
		{Key: "filter", Value: bson.D{{Key: "service", Value: "payments"}, {Key: "status", Value: bson.D{{Key: "$gte", Value: 500}}}}},
		{Key: "sort", Value: bson.D{{Key: "ts", Value: -1}}},
		{Key: "limit", Value: 5}}},
	{"медленный поиск", bson.D{
		{Key: "filter", Value: bson.D{{Key: "service", Value: "search"}, {Key: "duration_ms", Value: bson.D{{Key: "$gte", Value: 1000}}}}},
		{Key: "sort", Value: bson.D{{Key: "duration_ms", Value: -1}}},
		{Key: "limit", Value: 5}}},
	{"события покупателя", bson.D{
		{Key: "filter", Value: bson.D{{Key: "user_id", Value: "c-015"}}},
		{Key: "sort", Value: bson.D{{Key: "ts", Value: -1}}}}},
}

func report(sandbox *mongo.Database) {
	fmt.Printf("  %s %6s %7s %11s\n", pad("запрос", 20), "вернул", "ключей", "документов")
	for _, q := range queries {
		plan, returned, keys, docs := summary(sandbox, q.find)
		fmt.Printf("  %s %6d %7d %11d\n", pad(q.name, 20), returned, keys, docs)
		fmt.Println("      " + plan)
	}
}

// Причины отказа из errInfo: недостающие поля и нарушенные условия полей.
type schemaRule struct {
	OperatorName           string   `bson:"operatorName"`
	MissingProperties      []string `bson:"missingProperties"`
	PropertiesNotSatisfied []struct {
		PropertyName string `bson:"propertyName"`
		Details      []struct {
			OperatorName string `bson:"operatorName"`
		} `bson:"details"`
	} `bson:"propertiesNotSatisfied"`
}

func main() {
	uri := os.Getenv("MONGO_URI")
	if uri == "" {
		uri = "mongodb://localhost:27017"
	}
	client, err := mongo.Connect(options.Client().ApplyURI(uri))
	if err != nil {
		log.Fatal(err)
	}
	ctx := context.Background()
	defer client.Disconnect(ctx)

	events := client.Database("logs").Collection("events")
	sandbox := client.Database("sandbox")
	audit := sandbox.Collection("audit")
Ruby · mongoподключение и функция summary
require "mongo"

Mongo::Logger.logger.level = Logger::WARN

client = Mongo::Client.new(ENV.fetch("MONGO_URI", "mongodb://localhost:27017/"))
events = client.use("logs").database[:events]
sandbox = client.use("sandbox").database
audit = sandbox[:audit]

def title(text)
  puts "\n" + text
  puts "─" * text.length
end

# План и счётчики explain: стадии сверху вниз, вернул, ключей, документов.
def summary(sandbox, find)
  info = sandbox.command(explain: { find: "audit" }.merge(find), verbosity: "executionStats").first
  stage = info["queryPlanner"]["winningPlan"]
  stage = stage["queryPlan"] || stage
  steps = []
  while stage
    steps << stage["stage"] + (stage["indexName"] ? " " + stage["indexName"] : "")
    stage = stage["inputStage"]
  end
  stats = info["executionStats"]
  [steps.join(" → "), stats["nReturned"], stats["totalKeysExamined"], stats["totalDocsExamined"]]
end

§3Копия журнала и правило для событий

Первый раздел пересоздаёт копию: удаляет audit вместе с индексами и правилом, оставшимися от прошлого запуска, и копирует в неё все события logs.events. Второй раздел описывает событие правилом $jsonSchema: шесть обязательных полей, время — дата, сервис и уровень — из списков, статус — целое от 100 до 599, длительность — число не меньше нуля.

До включения правило применяется как фильтр: скрипт считает события, которые ему соответствуют и не соответствуют. Так выполняется первый шаг порядка из главы 3.1, §8 — проверить данные правилом до того, как правило начнёт отклонять записи. Затем команда collMod включает правило с уровнем strict и действием error.

Python · PyMongoкопия журнала и правило
# ── 1. Копия журнала ──────────────────────────────────────────────────────
title("1. Копия журнала в песочнице")

audit.drop()
audit.insert_many(events.find())
print(f"  событий в копии: {audit.count_documents({})}")
print(f"  индексов: {len(list(audit.list_indexes()))}")

# ── 2. Правило для событий ────────────────────────────────────────────────
title("2. Правило для событий")

RULE = {
    "bsonType": "object",
    "required": ["ts", "service", "level", "route", "status", "duration_ms"],
    "properties": {
        "ts": {"bsonType": "date"},
        "service": {"enum": ["api-gateway", "notifier", "orders", "payments", "search"]},
        "level": {"enum": ["info", "warn", "error"]},
        "route": {"bsonType": "string"},
        "status": {"bsonType": "int", "minimum": 100, "maximum": 599},
        "duration_ms": {"bsonType": "number", "minimum": 0},
    },
}
print(f"  соответствуют правилу:    {audit.count_documents({'$jsonSchema': RULE})}")
print(f"  не соответствуют правилу: {audit.count_documents({'$nor': [{'$jsonSchema': RULE}]})}")

sandbox.command("collMod", "audit", validator={"$jsonSchema": RULE},
                validationLevel="strict", validationAction="error")
print("  правило включено: strict, error")
C++ · mongocxxкопия журнала и правило
    // ── 1. Копия журнала ──────────────────────────────────────────────────
    title("1. Копия журнала в песочнице");

    audit.drop();
    std::vector<bsoncxx::document::value> all;
    for (auto&& doc : events.find(make_document())) all.emplace_back(doc);
    audit.insert_many(all);
    std::cout << "  событий в копии: " << audit.count_documents(make_document()) << std::endl;
    int indexes = 0;
    for (auto&& ix : audit.list_indexes()) {
        (void)ix;
        ++indexes;
    }
    std::cout << "  индексов: " << indexes << std::endl;

    // ── 2. Правило для событий ────────────────────────────────────────────
    title("2. Правило для событий");

    auto rule = make_document(
        kvp("bsonType", "object"),
        kvp("required", make_array("ts", "service", "level", "route", "status", "duration_ms")),
        kvp("properties", make_document(
            kvp("ts", make_document(kvp("bsonType", "date"))),
            kvp("service", make_document(kvp("enum", make_array("api-gateway", "notifier", "orders", "payments", "search")))),
            kvp("level", make_document(kvp("enum", make_array("info", "warn", "error")))),
            kvp("route", make_document(kvp("bsonType", "string"))),
            kvp("status", make_document(kvp("bsonType", "int"), kvp("minimum", 100), kvp("maximum", 599))),
            kvp("duration_ms", make_document(kvp("bsonType", "number"), kvp("minimum", 0))))));
    std::cout << "  соответствуют правилу:    "
              << audit.count_documents(make_document(kvp("$jsonSchema", rule.view()))) << std::endl;
    std::cout << "  не соответствуют правилу: "
              << audit.count_documents(make_document(kvp("$nor", make_array(make_document(kvp("$jsonSchema", rule.view()))))))
              << std::endl;

    sandbox.run_command(make_document(
        kvp("collMod", "audit"),
        kvp("validator", make_document(kvp("$jsonSchema", rule.view()))),
        kvp("validationLevel", "strict"),
        kvp("validationAction", "error")));
    std::cout << "  правило включено: strict, error" << std::endl;
Go · mongo-driverкопия журнала и правило
	// ── 1. Копия журнала ──────────────────────────────────────────────────
	title("1. Копия журнала в песочнице")

	audit.Drop(ctx)
	var all []bson.D
	cursor, _ := events.Find(ctx, bson.D{})
	cursor.All(ctx, &all)
	docs := make([]any, len(all))
	for i := range all {
		docs[i] = all[i]
	}
	audit.InsertMany(ctx, docs)
	n, _ := audit.CountDocuments(ctx, bson.D{})
	fmt.Printf("  событий в копии: %d\n", n)
	specs, _ := audit.Indexes().ListSpecifications(ctx)
	fmt.Printf("  индексов: %d\n", len(specs))

	// ── 2. Правило для событий ────────────────────────────────────────────
	title("2. Правило для событий")

	rule := bson.D{
		{Key: "bsonType", Value: "object"},
		{Key: "required", Value: bson.A{"ts", "service", "level", "route", "status", "duration_ms"}},
		{Key: "properties", Value: bson.D{
			{Key: "ts", Value: bson.D{{Key: "bsonType", Value: "date"}}},
			{Key: "service", Value: bson.D{{Key: "enum", Value: bson.A{"api-gateway", "notifier", "orders", "payments", "search"}}}},
			{Key: "level", Value: bson.D{{Key: "enum", Value: bson.A{"info", "warn", "error"}}}},
			{Key: "route", Value: bson.D{{Key: "bsonType", Value: "string"}}},
			{Key: "status", Value: bson.D{{Key: "bsonType", Value: "int"}, {Key: "minimum", Value: 100}, {Key: "maximum", Value: 599}}},
			{Key: "duration_ms", Value: bson.D{{Key: "bsonType", Value: "number"}, {Key: "minimum", Value: 0}}},
		}},
	}
	fits, _ := audit.CountDocuments(ctx, bson.D{{Key: "$jsonSchema", Value: rule}})
	misfits, _ := audit.CountDocuments(ctx, bson.D{{Key: "$nor", Value: bson.A{bson.D{{Key: "$jsonSchema", Value: rule}}}}})
	fmt.Printf("  соответствуют правилу:    %d\n", fits)
	fmt.Printf("  не соответствуют правилу: %d\n", misfits)

	sandbox.RunCommand(ctx, bson.D{
		{Key: "collMod", Value: "audit"},
		{Key: "validator", Value: bson.D{{Key: "$jsonSchema", Value: rule}}},
		{Key: "validationLevel", Value: "strict"},
		{Key: "validationAction", Value: "error"}})
	fmt.Println("  правило включено: strict, error")
Ruby · mongoкопия журнала и правило
# ── 1. Копия журнала ──────────────────────────────────────────────────────
title("1. Копия журнала в песочнице")

audit.drop
audit.insert_many(events.find.to_a)
puts "  событий в копии: #{audit.count_documents({})}"
puts "  индексов: #{audit.indexes.count}"

# ── 2. Правило для событий ────────────────────────────────────────────────
title("2. Правило для событий")

RULE = {
  "bsonType" => "object",
  "required" => ["ts", "service", "level", "route", "status", "duration_ms"],
  "properties" => {
    "ts" => { "bsonType" => "date" },
    "service" => { "enum" => ["api-gateway", "notifier", "orders", "payments", "search"] },
    "level" => { "enum" => ["info", "warn", "error"] },
    "route" => { "bsonType" => "string" },
    "status" => { "bsonType" => "int", "minimum" => 100, "maximum" => 599 },
    "duration_ms" => { "bsonType" => "number", "minimum" => 0 },
  },
}.freeze
puts "  соответствуют правилу:    #{audit.count_documents({ "$jsonSchema" => RULE })}"
puts "  не соответствуют правилу: #{audit.count_documents({ "$nor" => [{ "$jsonSchema" => RULE }] })}"

sandbox.command(collMod: "audit", validator: { "$jsonSchema" => RULE },
                validationLevel: "strict", validationAction: "error")
puts "  правило включено: strict, error"
Вывод в терминалразделы 1 и 2 отчёта
1. Копия журнала в песочнице
────────────────────────────
  событий в копии: 1200
  индексов: 1

2. Правило для событий
──────────────────────
  соответствуют правилу:    1200
  не соответствуют правилу: 0
  правило включено: strict, error

В копии 1200 событий и один индекс, _id_. Все 1200 событий соответствуют правилу, несоответствующих нет, поэтому правило включено сразу в строгом режиме. Если бы второе число было больше нуля, правило сначала включали бы с уровнем moderate и действием warn, а неверные события исправляли.

§4Неверные события

Третий раздел проверяет, что правило действует: скрипт пробует записать три события, каждое с одной ошибкой — статус 700, уровень fatal, которого нет в списке, и событие без времени. Отказ сервера перехватывается, и из errInfo собирается короткая строка причин: для недостающих полей — «нет» и имя поля, для нарушенных условий — имя поля и ключевое слово правила. Разбор errInfo тот же, что в главе 3.1, §4.

Python · PyMongoтри неверных события
# ── 3. Проверка правила на записи ─────────────────────────────────────────
title("3. Неверные события")

good = {"ts": datetime(2026, 9, 2, 18, 30), "service": "payments", "level": "error",
        "route": "/api/pay", "status": 500, "duration_ms": 300}
wrong = [
    ("статус 700", {**good, "status": 700}),
    ("уровень fatal", {**good, "level": "fatal"}),
    ("без времени", {key: value for key, value in good.items() if key != "ts"}),
]
for label, doc in wrong:
    try:
        audit.insert_one(doc)
        print(f"  {label:14} записано")
    except WriteError as e:
        reasons = []
        for rule in e.details["errInfo"]["details"]["schemaRulesNotSatisfied"]:
            if rule["operatorName"] == "required":
                reasons += [f"нет {name}" for name in rule["missingProperties"]]
            if rule["operatorName"] == "properties":
                for prop in rule["propertiesNotSatisfied"]:
                    reasons += [f"{prop['propertyName']}: {d['operatorName']}" for d in prop["details"]]
        print(f"  {label:14} отклонено, код {e.code}: {', '.join(reasons)}")
print(f"  событий в копии: {audit.count_documents({})}")
C++ · mongocxxтри неверных события
    // ── 3. Проверка правила на записи ─────────────────────────────────────
    title("3. Неверные события");

    auto ts = bsoncxx::types::b_date{std::chrono::milliseconds{1788373800000}};   // 02.09.2026 18:30 UTC
    std::vector<std::pair<std::string, bsoncxx::document::value>> wrong;
    wrong.emplace_back("статус 700", make_document(kvp("ts", ts), kvp("service", "payments"), kvp("level", "error"),
                                                   kvp("route", "/api/pay"), kvp("status", 700), kvp("duration_ms", 300)));
    wrong.emplace_back("уровень fatal", make_document(kvp("ts", ts), kvp("service", "payments"), kvp("level", "fatal"),
                                                      kvp("route", "/api/pay"), kvp("status", 500), kvp("duration_ms", 300)));
    wrong.emplace_back("без времени", make_document(kvp("service", "payments"), kvp("level", "error"),
                                                    kvp("route", "/api/pay"), kvp("status", 500), kvp("duration_ms", 300)));
    for (const auto& [label, doc] : wrong) {
        try {
            audit.insert_one(doc.view());
            std::cout << "  " << pad(label, 14) << " записано" << std::endl;
        } catch (const mongocxx::operation_exception& e) {
            std::string reasons;
            auto add = [&](const std::string& text) { reasons += (reasons.empty() ? "" : ", ") + text; };
            auto info = e.raw_server_error()->view()["writeErrors"][0]["errInfo"];
            for (auto&& item : info["details"]["schemaRulesNotSatisfied"].get_array().value) {
                auto r = item.get_document().value;
                std::string op{r["operatorName"].get_string().value};
                if (op == "required") {
                    for (auto&& name : r["missingProperties"].get_array().value)
                        add("нет " + std::string{name.get_string().value});
                }
                if (op == "properties") {
                    for (auto&& prop : r["propertiesNotSatisfied"].get_array().value)
                        for (auto&& d : prop["details"].get_array().value)
                            add(std::string{prop["propertyName"].get_string().value} + ": " +
                                std::string{d["operatorName"].get_string().value});
                }
            }
            std::cout << "  " << pad(label, 14) << " отклонено, код " << e.code().value() << ": " << reasons << std::endl;
        }
    }
    std::cout << "  событий в копии: " << audit.count_documents(make_document()) << std::endl;
Go · mongo-driverтри неверных события
	// ── 3. Проверка правила на записи ─────────────────────────────────────
	title("3. Неверные события")

	event := func(key string, value any) bson.D {
		doc := bson.D{
			{Key: "ts", Value: time.Date(2026, 9, 2, 18, 30, 0, 0, time.UTC)},
			{Key: "service", Value: "payments"},
			{Key: "level", Value: "error"},
			{Key: "route", Value: "/api/pay"},
			{Key: "status", Value: 500},
			{Key: "duration_ms", Value: 300}}
		out := bson.D{}
		for _, field := range doc {
			if field.Key != key {
				out = append(out, field)
			} else if value != nil {
				out = append(out, bson.E{Key: key, Value: value})
			}
		}
		return out
	}
	wrong := []struct {
		label string
		doc   bson.D
	}{
		{"статус 700", event("status", 700)},
		{"уровень fatal", event("level", "fatal")},
		{"без времени", event("ts", nil)},
	}
	for _, w := range wrong {
		_, err := audit.InsertOne(ctx, w.doc)
		var we mongo.WriteException
		if !errors.As(err, &we) {
			fmt.Printf("  %s записано\n", pad(w.label, 14))
			continue
		}
		var info struct {
			Details struct {
				Rules []schemaRule `bson:"schemaRulesNotSatisfied"`
			} `bson:"details"`
		}
		bson.Unmarshal(we.WriteErrors[0].Details, &info)
		reasons := []string{}
		for _, r := range info.Details.Rules {
			if r.OperatorName == "required" {
				for _, name := range r.MissingProperties {
					reasons = append(reasons, "нет "+name)
				}
			}
			if r.OperatorName == "properties" {
				for _, prop := range r.PropertiesNotSatisfied {
					for _, d := range prop.Details {
						reasons = append(reasons, prop.PropertyName+": "+d.OperatorName)
					}
				}
			}
		}
		fmt.Printf("  %s отклонено, код %d: %s\n", pad(w.label, 14), we.WriteErrors[0].Code, strings.Join(reasons, ", "))
	}
	n, _ = audit.CountDocuments(ctx, bson.D{})
	fmt.Printf("  событий в копии: %d\n", n)
Ruby · mongoтри неверных события
# ── 3. Проверка правила на записи ─────────────────────────────────────────
title("3. Неверные события")

good = { "ts" => Time.utc(2026, 9, 2, 18, 30), "service" => "payments", "level" => "error",
         "route" => "/api/pay", "status" => 500, "duration_ms" => 300 }
wrong = [
  ["статус 700", good.merge("status" => 700)],
  ["уровень fatal", good.merge("level" => "fatal")],
  ["без времени", good.reject { |key, _| key == "ts" }],
]
wrong.each do |label, doc|
  begin
    audit.insert_one(doc)
    puts "  #{label.ljust(14)} записано"
  rescue Mongo::Error::OperationFailure => e
    reasons = []
    e.details["details"]["schemaRulesNotSatisfied"].each do |rule|
      if rule["operatorName"] == "required"
        reasons += rule["missingProperties"].map { |name| "нет #{name}" }
      end
      if rule["operatorName"] == "properties"
        rule["propertiesNotSatisfied"].each do |prop|
          reasons += prop["details"].map { |d| "#{prop["propertyName"]}: #{d["operatorName"]}" }
        end
      end
    end
    puts "  #{label.ljust(14)} отклонено, код #{e.code}: #{reasons.join(", ")}"
  end
end
puts "  событий в копии: #{audit.count_documents({})}"
Вывод в терминалраздел 3 отчёта
3. Неверные события
───────────────────
  статус 700     отклонено, код 121: status: maximum
  уровень fatal  отклонено, код 121: level: enum
  без времени    отклонено, код 121: нет ts
  событий в копии: 1200

Все три события отклонены с кодом 121, и у каждого названа причина: у статуса нарушено maximum, у уровня — enum, в третьем событии нет поля ts. Событий в копии по-прежнему 1200.

§5Запросы дежурного без индексов

Три запроса, которые дежурные выполняют чаще всего, записаны списком: название и части команды find — фильтр, сортировка, предел.

  • ошибки платежей — пять последних событий сервиса payments со статусом от 500;
  • медленный поиск — пять самых долгих запросов сервиса search длительностью от секунды;
  • события покупателя — все события покупателя c-015, новые первыми.

Функция report проходит по списку, для каждого запроса вызывает summary и печатает строку таблицы — сколько документов запрос вернул, сколько ключей индекса и документов прочитано — и под ней стадии плана. Отчёт понадобится дважды, до индексов и после, поэтому он вынесен в функцию.

Список queries и функция report объявлены над main (§2); в этом разделе скрипта остаются заголовок и вызов.

Функции queries и report объявлены над main (§2); в этом разделе скрипта остаются заголовок и вызов.

Python · PyMongoзапросы дежурного и отчёт
# ── 4. Запросы дежурного без индексов ─────────────────────────────────────
QUERIES = [
    ("ошибки платежей", {"filter": {"service": "payments", "status": {"$gte": 500}},
                         "sort": {"ts": -1}, "limit": 5}),
    ("медленный поиск", {"filter": {"service": "search", "duration_ms": {"$gte": 1000}},
                         "sort": {"duration_ms": -1}, "limit": 5}),
    ("события покупателя", {"filter": {"user_id": "c-015"}, "sort": {"ts": -1}}),
]


def report():
    print(f"  {'запрос':20} {'вернул':>6} {'ключей':>7} {'документов':>11}")
    for name, find in QUERIES:
        plan, returned, keys, docs = summary(find)
        print(f"  {name:20} {returned:6} {keys:7} {docs:11}")
        print(f"      {plan}")


title("4. Запросы дежурного без индексов")
report()
C++ · mongocxxзапросы дежурного и отчёт
    // ── 4. Запросы дежурного без индексов ─────────────────────────────────
    title("4. Запросы дежурного без индексов");
    report(sandbox);
Go · mongo-driverзапросы дежурного и отчёт
	// ── 4. Запросы дежурного без индексов ─────────────────────────────────
	title("4. Запросы дежурного без индексов")
	report(sandbox)
Ruby · mongoзапросы дежурного и отчёт
# ── 4. Запросы дежурного без индексов ─────────────────────────────────────
QUERIES = [
  ["ошибки платежей", { filter: { "service" => "payments", "status" => { "$gte" => 500 } },
                        sort: { "ts" => -1 }, limit: 5 }],
  ["медленный поиск", { filter: { "service" => "search", "duration_ms" => { "$gte" => 1000 } },
                        sort: { "duration_ms" => -1 }, limit: 5 }],
  ["события покупателя", { filter: { "user_id" => "c-015" }, sort: { "ts" => -1 } }],
].freeze

def report(sandbox)
  puts "  #{"запрос".ljust(20)} #{"вернул".rjust(6)} #{"ключей".rjust(7)} #{"документов".rjust(11)}"
  QUERIES.each do |name, find|
    plan, returned, keys, docs = summary(sandbox, find)
    puts "  #{name.ljust(20)} #{returned.to_s.rjust(6)} #{keys.to_s.rjust(7)} #{docs.to_s.rjust(11)}"
    puts "      #{plan}"
  end
end

title("4. Запросы дежурного без индексов")
report(sandbox)
Вывод в терминалраздел 4 отчёта
4. Запросы дежурного без индексов
─────────────────────────────────
  запрос               вернул  ключей  документов
  ошибки платежей           5       0        1200
      SORT → COLLSCAN
  медленный поиск           5       0        1200
      SORT → COLLSCAN
  события покупателя       32       0        1200
      SORT → COLLSCAN

У всех трёх запросов план SORT → COLLSCAN: сервер читает все 1200 событий и сортирует подходящие в памяти. Первые два запроса возвращают по 5 документов, прочитав 1200.

§6Индексы и те же запросы

Пятый раздел строит по индексу на каждый запрос, порядок полей — по правилу ESR из главы 3.3, §6: равенство, сортировка, диапазон.

ЗапросРавенствоСортировкаДиапазонИндекс
ошибки платежейservicets ↓statusservice, ts ↓, status
медленный поискserviceduration_ms ↓duration_msservice, duration_ms ↓
события покупателяuser_idts ↓—user_id, ts ↓

Во втором запросе сортировка и диапазон — по одному и тому же полю duration_ms, и в индексе оно стоит один раз. Шестой раздел вызывает report ещё раз, уже с индексами.

Python · PyMongoиндексы и повторный отчёт
# ── 5. Индексы по правилу ESR ─────────────────────────────────────────────
title("5. Индексы: равенство, сортировка, диапазон")

for keys in ([("service", 1), ("ts", -1), ("status", 1)],
             [("service", 1), ("duration_ms", -1)],
             [("user_id", 1), ("ts", -1)]):
    print(f"  создан {audit.create_index(keys)}")

# ── 6. Те же запросы с индексами ──────────────────────────────────────────
title("6. Запросы дежурного с индексами")
report()
C++ · mongocxxиндексы и повторный отчёт
    // ── 5. Индексы по правилу ESR ─────────────────────────────────────────
    title("5. Индексы: равенство, сортировка, диапазон");

    std::vector<bsoncxx::document::value> keys;
    keys.push_back(make_document(kvp("service", 1), kvp("ts", -1), kvp("status", 1)));
    keys.push_back(make_document(kvp("service", 1), kvp("duration_ms", -1)));
    keys.push_back(make_document(kvp("user_id", 1), kvp("ts", -1)));
    for (const auto& key : keys) {
        auto created = audit.create_index(key.view());
        std::cout << "  создан " << created.view()["name"].get_string().value << std::endl;
    }

    // ── 6. Те же запросы с индексами ──────────────────────────────────────
    title("6. Запросы дежурного с индексами");
    report(sandbox);
Go · mongo-driverиндексы и повторный отчёт
	// ── 5. Индексы по правилу ESR ─────────────────────────────────────────
	title("5. Индексы: равенство, сортировка, диапазон")

	for _, keys := range []bson.D{
		{{Key: "service", Value: 1}, {Key: "ts", Value: -1}, {Key: "status", Value: 1}},
		{{Key: "service", Value: 1}, {Key: "duration_ms", Value: -1}},
		{{Key: "user_id", Value: 1}, {Key: "ts", Value: -1}},
	} {
		name, _ := audit.Indexes().CreateOne(ctx, mongo.IndexModel{Keys: keys})
		fmt.Println("  создан " + name)
	}

	// ── 6. Те же запросы с индексами ──────────────────────────────────────
	title("6. Запросы дежурного с индексами")
	report(sandbox)
Ruby · mongoиндексы и повторный отчёт
# ── 5. Индексы по правилу ESR ─────────────────────────────────────────────
title("5. Индексы: равенство, сортировка, диапазон")

[{ "service" => 1, "ts" => -1, "status" => 1 },
 { "service" => 1, "duration_ms" => -1 },
 { "user_id" => 1, "ts" => -1 }].each do |keys|
  audit.indexes.create_one(keys)
  puts "  создан #{audit.indexes.get(keys)["name"]}"
end

# ── 6. Те же запросы с индексами ──────────────────────────────────────────
title("6. Запросы дежурного с индексами")
report(sandbox)
Вывод в терминалразделы 5 и 6 отчёта
5. Индексы: равенство, сортировка, диапазон
───────────────────────────────────────────
  создан service_1_ts_-1_status_1
  создан service_1_duration_ms_-1
  создан user_id_1_ts_-1

6. Запросы дежурного с индексами
────────────────────────────────
  запрос               вернул  ключей  документов
  ошибки платежей           5      18           5
      LIMIT → FETCH → IXSCAN service_1_ts_-1_status_1
  медленный поиск           5       5           5
      LIMIT → FETCH → IXSCAN service_1_duration_ms_-1
  события покупателя       32      32          32
      FETCH → IXSCAN user_id_1_ts_-1

Стадии SORT нет ни в одном плане. «Ошибки платежей»: прочитано 18 записей индекса и 5 документов вместо 1200. «Медленный поиск»: 5 и 5 — записи индекса уже упорядочены по убыванию длительности, и сервер берёт первые пять. «События покупателя»: 32 и 32 — столько же, сколько возвращено.

§7Найденные события

Последний раздел выполняет первый запрос обычным find и печатает сами события: время, маршрут, статус и длительность. Отчёт explain документов не возвращает, поэтому результат запроса получают отдельным вызовом.

Python · PyMongoпять последних ошибок платежей
# ── 7. Пять последних ошибок платежей ─────────────────────────────────────
title("7. Пять последних ошибок платежей")

name, find = QUERIES[0]
for doc in audit.find(find["filter"]).sort("ts", -1).limit(find["limit"]):
    print(f"  {doc['ts']:%d.%m %H:%M:%S}  {doc['route']:18} {doc['status']}  {doc['duration_ms']:5} мс")

client.close()
C++ · mongocxxпять последних ошибок платежей
    // ── 7. Пять последних ошибок платежей ─────────────────────────────────
    title("7. Пять последних ошибок платежей");

    mongocxx::options::find newest;
    newest.sort(make_document(kvp("ts", -1)));
    newest.limit(5);
    auto errors = make_document(kvp("service", "payments"), kvp("status", make_document(kvp("$gte", 500))));
    for (auto&& doc : audit.find(errors.view(), newest)) {
        std::time_t seconds = doc["ts"].get_date().to_int64() / 1000;
        std::ostringstream when;
        when << std::put_time(std::gmtime(&seconds), "%d.%m %H:%M:%S");
        std::cout << "  " << when.str() << "  " << pad(std::string{doc["route"].get_string().value}, 18) << " "
                  << number(doc["status"]) << "  " << std::setw(5) << number(doc["duration_ms"]) << " мс" << std::endl;
    }
}
Go · mongo-driverпять последних ошибок платежей
	// ── 7. Пять последних ошибок платежей ─────────────────────────────────
	title("7. Пять последних ошибок платежей")

	filtr := queries[0].find[0].Value // фильтр первого запроса: первое поле его команды find
	last, _ := audit.Find(ctx, filtr, options.Find().SetSort(bson.D{{Key: "ts", Value: -1}}).SetLimit(5))
	for last.Next(ctx) {
		doc := last.Current
		ts := doc.Lookup("ts").Time().UTC()
		fmt.Printf("  %s  %s %d  %5d мс\n", ts.Format("02.01 15:04:05"), pad(doc.Lookup("route").StringValue(), 18),
			doc.Lookup("status").AsInt64(), doc.Lookup("duration_ms").AsInt64())
	}
}
Ruby · mongoпять последних ошибок платежей
# ── 7. Пять последних ошибок платежей ─────────────────────────────────────
title("7. Пять последних ошибок платежей")

_, find = QUERIES[0]
audit.find(find[:filter]).sort({ "ts" => -1 }).limit(find[:limit]).each do |doc|
  puts "  #{doc["ts"].utc.strftime("%d.%m %H:%M:%S")}  #{doc["route"].ljust(18)} #{doc["status"]}  " \
       "#{doc["duration_ms"].to_s.rjust(5)} мс"
end

client.close
Вывод в терминалраздел 7 отчёта
7. Пять последних ошибок платежей
─────────────────────────────────
  02.09 18:13:22  /api/orders        500    167 мс
  02.09 18:07:16  /api/orders        503   1185 мс
  02.09 17:21:47  /api/orders        500   2043 мс
  02.09 17:04:34  /api/products      500    952 мс
  02.09 14:53:42  /api/login         503   1288 мс

Пять событий идут от новых к старым. Время хранится в базе в UTC и печатается без перевода в местное.

§8Что показал отчёт

Разделы 4 и 6 отчёта дают сравнение «до и после» в одних и тех же единицах — числе прочитанных документов.

ЗапросВернулДокументов без индексаДокументов с индексомКлючей с индексом
ошибки платежей51200518
медленный поиск5120055
события покупателя3212003232

В первом запросе ключей прочитано больше, чем документов: сервер идёт по событиям payments от новых к старым и проверяет статус по записи индекса, пока не наберёт пять подходящих; восемнадцать записей пришлось просмотреть, чтобы найти пять со статусом от 500. Документы при этом читаются только для подходящих записей.

У трёх индексов есть цена: каждая запись события в журнал теперь обновляет четыре индекса вместо одного (глава 3.2, §8). Для журнала, в который пишут чаще, чем читают, это существенно, поэтому индексы строят под запросы, которые выполняются часто.

Compass, вкладка Indexes коллекции sandbox.audit после скрипта: _id_ 4,1 kB и три составных индекса размером 32,8, 28,7 и 36,9 kB
Compass · sandbox.audit, вкладка Indexes после запуска скрипта

После запуска у коллекции четыре индекса. Три составных занимают 32,8, 28,7 и 36,9 kB — в сумме около 98 kB на 1200 событий, тогда как индекс _id_ — 4,1 kB. Столбец Usage показывает, сколько раз индекс использовали запросы; индекс с нулём в этом столбце спустя недели работы — кандидат на удаление.

§9Скрипт целиком

Ниже — тот же файл без пропусков, а под ним вывод его запуска на стенде курса.

Python · PyMongoскрипт целиком
"""Аудит журнала сервиса: что умеют правила, индексы и explain из модуля 3.

Скрипт работает с копией журнала logs.events в песочнице: проверяет события
правилом $jsonSchema и включает его как валидатор, снимает планы трёх
запросов дежурного, строит под них индексы по правилу ESR и сравнивает
планы до и после. База logs остаётся без изменений.

    cd scripts
    python3 logs_audit.py

    docker compose run --rm python scripts/logs_audit.py    # в контейнере
"""

import os
from datetime import datetime

from pymongo import MongoClient
from pymongo.errors import WriteError

client = MongoClient(os.environ.get("MONGO_URI", "mongodb://localhost:27017/"))
events = client["logs"]["events"]
sandbox = client["sandbox"]
audit = sandbox["audit"]


def title(text):
    print("\n" + text)
    print("─" * len(text))


def summary(find):
    """План и счётчики explain: стадии сверху вниз, вернул, ключей, документов."""
    info = sandbox.command("explain", {"find": "audit", **find}, verbosity="executionStats")
    stage = info["queryPlanner"]["winningPlan"]
    stage = stage.get("queryPlan", stage)
    steps = []
    while stage:
        steps.append(stage["stage"] + (" " + stage["indexName"] if "indexName" in stage else ""))
        stage = stage.get("inputStage")
    stats = info["executionStats"]
    return " → ".join(steps), stats["nReturned"], stats["totalKeysExamined"], stats["totalDocsExamined"]


# ── 1. Копия журнала ──────────────────────────────────────────────────────
title("1. Копия журнала в песочнице")

audit.drop()
audit.insert_many(events.find())
print(f"  событий в копии: {audit.count_documents({})}")
print(f"  индексов: {len(list(audit.list_indexes()))}")

# ── 2. Правило для событий ────────────────────────────────────────────────
title("2. Правило для событий")

RULE = {
    "bsonType": "object",
    "required": ["ts", "service", "level", "route", "status", "duration_ms"],
    "properties": {
        "ts": {"bsonType": "date"},
        "service": {"enum": ["api-gateway", "notifier", "orders", "payments", "search"]},
        "level": {"enum": ["info", "warn", "error"]},
        "route": {"bsonType": "string"},
        "status": {"bsonType": "int", "minimum": 100, "maximum": 599},
        "duration_ms": {"bsonType": "number", "minimum": 0},
    },
}
print(f"  соответствуют правилу:    {audit.count_documents({'$jsonSchema': RULE})}")
print(f"  не соответствуют правилу: {audit.count_documents({'$nor': [{'$jsonSchema': RULE}]})}")

sandbox.command("collMod", "audit", validator={"$jsonSchema": RULE},
                validationLevel="strict", validationAction="error")
print("  правило включено: strict, error")

# ── 3. Проверка правила на записи ─────────────────────────────────────────
title("3. Неверные события")

good = {"ts": datetime(2026, 9, 2, 18, 30), "service": "payments", "level": "error",
        "route": "/api/pay", "status": 500, "duration_ms": 300}
wrong = [
    ("статус 700", {**good, "status": 700}),
    ("уровень fatal", {**good, "level": "fatal"}),
    ("без времени", {key: value for key, value in good.items() if key != "ts"}),
]
for label, doc in wrong:
    try:
        audit.insert_one(doc)
        print(f"  {label:14} записано")
    except WriteError as e:
        reasons = []
        for rule in e.details["errInfo"]["details"]["schemaRulesNotSatisfied"]:
            if rule["operatorName"] == "required":
                reasons += [f"нет {name}" for name in rule["missingProperties"]]
            if rule["operatorName"] == "properties":
                for prop in rule["propertiesNotSatisfied"]:
                    reasons += [f"{prop['propertyName']}: {d['operatorName']}" for d in prop["details"]]
        print(f"  {label:14} отклонено, код {e.code}: {', '.join(reasons)}")
print(f"  событий в копии: {audit.count_documents({})}")

# ── 4. Запросы дежурного без индексов ─────────────────────────────────────
QUERIES = [
    ("ошибки платежей", {"filter": {"service": "payments", "status": {"$gte": 500}},
                         "sort": {"ts": -1}, "limit": 5}),
    ("медленный поиск", {"filter": {"service": "search", "duration_ms": {"$gte": 1000}},
                         "sort": {"duration_ms": -1}, "limit": 5}),
    ("события покупателя", {"filter": {"user_id": "c-015"}, "sort": {"ts": -1}}),
]


def report():
    print(f"  {'запрос':20} {'вернул':>6} {'ключей':>7} {'документов':>11}")
    for name, find in QUERIES:
        plan, returned, keys, docs = summary(find)
        print(f"  {name:20} {returned:6} {keys:7} {docs:11}")
        print(f"      {plan}")


title("4. Запросы дежурного без индексов")
report()

# ── 5. Индексы по правилу ESR ─────────────────────────────────────────────
title("5. Индексы: равенство, сортировка, диапазон")

for keys in ([("service", 1), ("ts", -1), ("status", 1)],
             [("service", 1), ("duration_ms", -1)],
             [("user_id", 1), ("ts", -1)]):
    print(f"  создан {audit.create_index(keys)}")

# ── 6. Те же запросы с индексами ──────────────────────────────────────────
title("6. Запросы дежурного с индексами")
report()

# ── 7. Пять последних ошибок платежей ─────────────────────────────────────
title("7. Пять последних ошибок платежей")

name, find = QUERIES[0]
for doc in audit.find(find["filter"]).sort("ts", -1).limit(find["limit"]):
    print(f"  {doc['ts']:%d.%m %H:%M:%S}  {doc['route']:18} {doc['status']}  {doc['duration_ms']:5} мс")

client.close()
C++ · mongocxxскрипт целиком
// Аудит журнала сервиса: что умеют правила, индексы и explain из модуля 3.
//
// Скрипт работает с копией журнала logs.events в песочнице: проверяет события
// правилом $jsonSchema и включает его как валидатор, снимает планы трёх
// запросов дежурного, строит под них индексы по правилу ESR и сравнивает
// планы до и после. База logs остаётся без изменений.
//
//     cd scripts
//     g++ -std=c++17 logs_audit.cpp -o logs_audit $(pkg-config --cflags --libs libmongocxx1) && ./logs_audit
//
//     docker compose run --rm cpp scripts/logs_audit.cpp    # в контейнере

#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <ctime>
#include <iomanip>
#include <iostream>
#include <sstream>
#include <string>
#include <tuple>
#include <vector>

#include <bsoncxx/builder/basic/array.hpp>
#include <bsoncxx/builder/basic/document.hpp>
#include <bsoncxx/builder/basic/kvp.hpp>
#include <bsoncxx/json.hpp>
#include <bsoncxx/types.hpp>
#include <mongocxx/client.hpp>
#include <mongocxx/collection.hpp>
#include <mongocxx/exception/operation_exception.hpp>
#include <mongocxx/instance.hpp>
#include <mongocxx/options/find.hpp>
#include <mongocxx/uri.hpp>

using bsoncxx::builder::basic::kvp;
using bsoncxx::builder::basic::make_array;
using bsoncxx::builder::basic::make_document;

int simvolov(const std::string& text) {
    int n = 0;
    for (unsigned char c : text)
        if ((c & 0xC0) != 0x80) ++n;
    return n;
}

std::string pad(const std::string& text, int width) {
    int add = width - simvolov(text);
    return add > 0 ? text + std::string(add, ' ') : text;
}

std::string right(const std::string& text, int width) {
    int add = width - simvolov(text);
    return add > 0 ? std::string(add, ' ') + text : text;
}

void title(const std::string& text) {
    std::cout << "\n" << text << "\n";
    for (int i = 0; i < simvolov(text); ++i) std::cout << "─";
    std::cout << std::endl;
}

// Число из ответа сервера: счётчики приходят как int32 или int64.
std::int64_t number(bsoncxx::document::element value) {
    return value.type() == bsoncxx::type::k_int64 ? value.get_int64().value : value.get_int32().value;
}

// План и счётчики explain: стадии сверху вниз, вернул, ключей, документов.
std::tuple<std::string, std::int64_t, std::int64_t, std::int64_t> summary(mongocxx::database& sandbox,
                                                                          bsoncxx::document::view find) {
    bsoncxx::builder::basic::document cmd;
    cmd.append(kvp("find", "audit"));
    for (auto&& field : find) cmd.append(kvp(field.key(), field.get_value()));
    auto info = sandbox.run_command(make_document(kvp("explain", cmd.view()), kvp("verbosity", "executionStats")));
    auto stage = info.view()["queryPlanner"]["winningPlan"].get_document().value;
    if (stage["queryPlan"]) stage = stage["queryPlan"].get_document().value;
    std::string steps;
    while (true) {
        steps += std::string{stage["stage"].get_string().value};
        if (stage["indexName"]) steps += " " + std::string{stage["indexName"].get_string().value};
        if (!stage["inputStage"]) break;
        steps += " → ";
        stage = stage["inputStage"].get_document().value;
    }
    auto stats = info.view()["executionStats"].get_document().value;
    return {steps, number(stats["nReturned"]), number(stats["totalKeysExamined"]), number(stats["totalDocsExamined"])};
}

// Запросы дежурного: название и части команды find.
std::vector<std::pair<std::string, bsoncxx::document::value>> queries() {
    std::vector<std::pair<std::string, bsoncxx::document::value>> out;
    out.emplace_back("ошибки платежей", make_document(
        kvp("filter", make_document(kvp("service", "payments"), kvp("status", make_document(kvp("$gte", 500))))),
        kvp("sort", make_document(kvp("ts", -1))),
        kvp("limit", 5)));
    out.emplace_back("медленный поиск", make_document(
        kvp("filter", make_document(kvp("service", "search"), kvp("duration_ms", make_document(kvp("$gte", 1000))))),
        kvp("sort", make_document(kvp("duration_ms", -1))),
        kvp("limit", 5)));
    out.emplace_back("события покупателя", make_document(
        kvp("filter", make_document(kvp("user_id", "c-015"))),
        kvp("sort", make_document(kvp("ts", -1)))));
    return out;
}

void report(mongocxx::database& sandbox) {
    std::cout << "  " << pad("запрос", 20) << " " << right("вернул", 6) << " " << right("ключей", 7) << " "
              << right("документов", 11) << std::endl;
    for (const auto& [name, find] : queries()) {
        auto [plan, returned, keys, docs] = summary(sandbox, find.view());
        std::cout << "  " << pad(name, 20) << " " << std::setw(6) << returned << " " << std::setw(7) << keys << " "
                  << std::setw(11) << docs << std::endl;
        std::cout << "      " << plan << std::endl;
    }
}

int main() {
    mongocxx::instance instance{};
    const char* env = std::getenv("MONGO_URI");
    mongocxx::client client{mongocxx::uri{env ? env : "mongodb://localhost:27017"}};

    auto events = client["logs"]["events"];
    auto sandbox = client["sandbox"];
    auto audit = sandbox["audit"];

    // ── 1. Копия журнала ──────────────────────────────────────────────────
    title("1. Копия журнала в песочнице");

    audit.drop();
    std::vector<bsoncxx::document::value> all;
    for (auto&& doc : events.find(make_document())) all.emplace_back(doc);
    audit.insert_many(all);
    std::cout << "  событий в копии: " << audit.count_documents(make_document()) << std::endl;
    int indexes = 0;
    for (auto&& ix : audit.list_indexes()) {
        (void)ix;
        ++indexes;
    }
    std::cout << "  индексов: " << indexes << std::endl;

    // ── 2. Правило для событий ────────────────────────────────────────────
    title("2. Правило для событий");

    auto rule = make_document(
        kvp("bsonType", "object"),
        kvp("required", make_array("ts", "service", "level", "route", "status", "duration_ms")),
        kvp("properties", make_document(
            kvp("ts", make_document(kvp("bsonType", "date"))),
            kvp("service", make_document(kvp("enum", make_array("api-gateway", "notifier", "orders", "payments", "search")))),
            kvp("level", make_document(kvp("enum", make_array("info", "warn", "error")))),
            kvp("route", make_document(kvp("bsonType", "string"))),
            kvp("status", make_document(kvp("bsonType", "int"), kvp("minimum", 100), kvp("maximum", 599))),
            kvp("duration_ms", make_document(kvp("bsonType", "number"), kvp("minimum", 0))))));
    std::cout << "  соответствуют правилу:    "
              << audit.count_documents(make_document(kvp("$jsonSchema", rule.view()))) << std::endl;
    std::cout << "  не соответствуют правилу: "
              << audit.count_documents(make_document(kvp("$nor", make_array(make_document(kvp("$jsonSchema", rule.view()))))))
              << std::endl;

    sandbox.run_command(make_document(
        kvp("collMod", "audit"),
        kvp("validator", make_document(kvp("$jsonSchema", rule.view()))),
        kvp("validationLevel", "strict"),
        kvp("validationAction", "error")));
    std::cout << "  правило включено: strict, error" << std::endl;

    // ── 3. Проверка правила на записи ─────────────────────────────────────
    title("3. Неверные события");

    auto ts = bsoncxx::types::b_date{std::chrono::milliseconds{1788373800000}};   // 02.09.2026 18:30 UTC
    std::vector<std::pair<std::string, bsoncxx::document::value>> wrong;
    wrong.emplace_back("статус 700", make_document(kvp("ts", ts), kvp("service", "payments"), kvp("level", "error"),
                                                   kvp("route", "/api/pay"), kvp("status", 700), kvp("duration_ms", 300)));
    wrong.emplace_back("уровень fatal", make_document(kvp("ts", ts), kvp("service", "payments"), kvp("level", "fatal"),
                                                      kvp("route", "/api/pay"), kvp("status", 500), kvp("duration_ms", 300)));
    wrong.emplace_back("без времени", make_document(kvp("service", "payments"), kvp("level", "error"),
                                                    kvp("route", "/api/pay"), kvp("status", 500), kvp("duration_ms", 300)));
    for (const auto& [label, doc] : wrong) {
        try {
            audit.insert_one(doc.view());
            std::cout << "  " << pad(label, 14) << " записано" << std::endl;
        } catch (const mongocxx::operation_exception& e) {
            std::string reasons;
            auto add = [&](const std::string& text) { reasons += (reasons.empty() ? "" : ", ") + text; };
            auto info = e.raw_server_error()->view()["writeErrors"][0]["errInfo"];
            for (auto&& item : info["details"]["schemaRulesNotSatisfied"].get_array().value) {
                auto r = item.get_document().value;
                std::string op{r["operatorName"].get_string().value};
                if (op == "required") {
                    for (auto&& name : r["missingProperties"].get_array().value)
                        add("нет " + std::string{name.get_string().value});
                }
                if (op == "properties") {
                    for (auto&& prop : r["propertiesNotSatisfied"].get_array().value)
                        for (auto&& d : prop["details"].get_array().value)
                            add(std::string{prop["propertyName"].get_string().value} + ": " +
                                std::string{d["operatorName"].get_string().value});
                }
            }
            std::cout << "  " << pad(label, 14) << " отклонено, код " << e.code().value() << ": " << reasons << std::endl;
        }
    }
    std::cout << "  событий в копии: " << audit.count_documents(make_document()) << std::endl;

    // ── 4. Запросы дежурного без индексов ─────────────────────────────────
    title("4. Запросы дежурного без индексов");
    report(sandbox);

    // ── 5. Индексы по правилу ESR ─────────────────────────────────────────
    title("5. Индексы: равенство, сортировка, диапазон");

    std::vector<bsoncxx::document::value> keys;
    keys.push_back(make_document(kvp("service", 1), kvp("ts", -1), kvp("status", 1)));
    keys.push_back(make_document(kvp("service", 1), kvp("duration_ms", -1)));
    keys.push_back(make_document(kvp("user_id", 1), kvp("ts", -1)));
    for (const auto& key : keys) {
        auto created = audit.create_index(key.view());
        std::cout << "  создан " << created.view()["name"].get_string().value << std::endl;
    }

    // ── 6. Те же запросы с индексами ──────────────────────────────────────
    title("6. Запросы дежурного с индексами");
    report(sandbox);

    // ── 7. Пять последних ошибок платежей ─────────────────────────────────
    title("7. Пять последних ошибок платежей");

    mongocxx::options::find newest;
    newest.sort(make_document(kvp("ts", -1)));
    newest.limit(5);
    auto errors = make_document(kvp("service", "payments"), kvp("status", make_document(kvp("$gte", 500))));
    for (auto&& doc : audit.find(errors.view(), newest)) {
        std::time_t seconds = doc["ts"].get_date().to_int64() / 1000;
        std::ostringstream when;
        when << std::put_time(std::gmtime(&seconds), "%d.%m %H:%M:%S");
        std::cout << "  " << when.str() << "  " << pad(std::string{doc["route"].get_string().value}, 18) << " "
                  << number(doc["status"]) << "  " << std::setw(5) << number(doc["duration_ms"]) << " мс" << std::endl;
    }
}
Go · mongo-driverскрипт целиком
// Аудит журнала сервиса: что умеют правила, индексы и explain из модуля 3.
//
// Скрипт работает с копией журнала logs.events в песочнице: проверяет события
// правилом $jsonSchema и включает его как валидатор, снимает планы трёх
// запросов дежурного, строит под них индексы по правилу ESR и сравнивает
// планы до и после. База logs остаётся без изменений.
//
//	cd scripts
//	go run logs_audit.go
//
//	docker compose run --rm go scripts/logs_audit.go    # в контейнере
package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"os"
	"strings"
	"time"
	"unicode/utf8"

	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

func pad(text string, width int) string {
	if n := utf8.RuneCountInString(text); n < width {
		return text + strings.Repeat(" ", width-n)
	}
	return text
}

func title(text string) {
	fmt.Println("\n" + text)
	fmt.Println(strings.Repeat("─", utf8.RuneCountInString(text)))
}

// summary — план и счётчики explain: стадии сверху вниз, вернул, ключей, документов.
func summary(sandbox *mongo.Database, find bson.D) (string, int64, int64, int64) {
	cmd := append(bson.D{{Key: "find", Value: "audit"}}, find...)
	var info bson.Raw
	sandbox.RunCommand(context.Background(),
		bson.D{{Key: "explain", Value: cmd}, {Key: "verbosity", Value: "executionStats"}}).Decode(&info)
	stage := info.Lookup("queryPlanner", "winningPlan").Document()
	if inner, ok := stage.Lookup("queryPlan").DocumentOK(); ok {
		stage = inner
	}
	steps := []string{}
	for {
		step := stage.Lookup("stage").StringValue()
		if name, ok := stage.Lookup("indexName").StringValueOK(); ok {
			step += " " + name
		}
		steps = append(steps, step)
		next, ok := stage.Lookup("inputStage").DocumentOK()
		if !ok {
			break
		}
		stage = next
	}
	stats := info.Lookup("executionStats").Document()
	return strings.Join(steps, " → "), stats.Lookup("nReturned").AsInt64(),
		stats.Lookup("totalKeysExamined").AsInt64(), stats.Lookup("totalDocsExamined").AsInt64()
}

// Запрос дежурного: название и части команды find.
type query struct {
	name string
	find bson.D
}

var queries = []query{
	{"ошибки платежей", bson.D{
		{Key: "filter", Value: bson.D{{Key: "service", Value: "payments"}, {Key: "status", Value: bson.D{{Key: "$gte", Value: 500}}}}},
		{Key: "sort", Value: bson.D{{Key: "ts", Value: -1}}},
		{Key: "limit", Value: 5}}},
	{"медленный поиск", bson.D{
		{Key: "filter", Value: bson.D{{Key: "service", Value: "search"}, {Key: "duration_ms", Value: bson.D{{Key: "$gte", Value: 1000}}}}},
		{Key: "sort", Value: bson.D{{Key: "duration_ms", Value: -1}}},
		{Key: "limit", Value: 5}}},
	{"события покупателя", bson.D{
		{Key: "filter", Value: bson.D{{Key: "user_id", Value: "c-015"}}},
		{Key: "sort", Value: bson.D{{Key: "ts", Value: -1}}}}},
}

func report(sandbox *mongo.Database) {
	fmt.Printf("  %s %6s %7s %11s\n", pad("запрос", 20), "вернул", "ключей", "документов")
	for _, q := range queries {
		plan, returned, keys, docs := summary(sandbox, q.find)
		fmt.Printf("  %s %6d %7d %11d\n", pad(q.name, 20), returned, keys, docs)
		fmt.Println("      " + plan)
	}
}

// Причины отказа из errInfo: недостающие поля и нарушенные условия полей.
type schemaRule struct {
	OperatorName           string   `bson:"operatorName"`
	MissingProperties      []string `bson:"missingProperties"`
	PropertiesNotSatisfied []struct {
		PropertyName string `bson:"propertyName"`
		Details      []struct {
			OperatorName string `bson:"operatorName"`
		} `bson:"details"`
	} `bson:"propertiesNotSatisfied"`
}

func main() {
	uri := os.Getenv("MONGO_URI")
	if uri == "" {
		uri = "mongodb://localhost:27017"
	}
	client, err := mongo.Connect(options.Client().ApplyURI(uri))
	if err != nil {
		log.Fatal(err)
	}
	ctx := context.Background()
	defer client.Disconnect(ctx)

	events := client.Database("logs").Collection("events")
	sandbox := client.Database("sandbox")
	audit := sandbox.Collection("audit")

	// ── 1. Копия журнала ──────────────────────────────────────────────────
	title("1. Копия журнала в песочнице")

	audit.Drop(ctx)
	var all []bson.D
	cursor, _ := events.Find(ctx, bson.D{})
	cursor.All(ctx, &all)
	docs := make([]any, len(all))
	for i := range all {
		docs[i] = all[i]
	}
	audit.InsertMany(ctx, docs)
	n, _ := audit.CountDocuments(ctx, bson.D{})
	fmt.Printf("  событий в копии: %d\n", n)
	specs, _ := audit.Indexes().ListSpecifications(ctx)
	fmt.Printf("  индексов: %d\n", len(specs))

	// ── 2. Правило для событий ────────────────────────────────────────────
	title("2. Правило для событий")

	rule := bson.D{
		{Key: "bsonType", Value: "object"},
		{Key: "required", Value: bson.A{"ts", "service", "level", "route", "status", "duration_ms"}},
		{Key: "properties", Value: bson.D{
			{Key: "ts", Value: bson.D{{Key: "bsonType", Value: "date"}}},
			{Key: "service", Value: bson.D{{Key: "enum", Value: bson.A{"api-gateway", "notifier", "orders", "payments", "search"}}}},
			{Key: "level", Value: bson.D{{Key: "enum", Value: bson.A{"info", "warn", "error"}}}},
			{Key: "route", Value: bson.D{{Key: "bsonType", Value: "string"}}},
			{Key: "status", Value: bson.D{{Key: "bsonType", Value: "int"}, {Key: "minimum", Value: 100}, {Key: "maximum", Value: 599}}},
			{Key: "duration_ms", Value: bson.D{{Key: "bsonType", Value: "number"}, {Key: "minimum", Value: 0}}},
		}},
	}
	fits, _ := audit.CountDocuments(ctx, bson.D{{Key: "$jsonSchema", Value: rule}})
	misfits, _ := audit.CountDocuments(ctx, bson.D{{Key: "$nor", Value: bson.A{bson.D{{Key: "$jsonSchema", Value: rule}}}}})
	fmt.Printf("  соответствуют правилу:    %d\n", fits)
	fmt.Printf("  не соответствуют правилу: %d\n", misfits)

	sandbox.RunCommand(ctx, bson.D{
		{Key: "collMod", Value: "audit"},
		{Key: "validator", Value: bson.D{{Key: "$jsonSchema", Value: rule}}},
		{Key: "validationLevel", Value: "strict"},
		{Key: "validationAction", Value: "error"}})
	fmt.Println("  правило включено: strict, error")

	// ── 3. Проверка правила на записи ─────────────────────────────────────
	title("3. Неверные события")

	event := func(key string, value any) bson.D {
		doc := bson.D{
			{Key: "ts", Value: time.Date(2026, 9, 2, 18, 30, 0, 0, time.UTC)},
			{Key: "service", Value: "payments"},
			{Key: "level", Value: "error"},
			{Key: "route", Value: "/api/pay"},
			{Key: "status", Value: 500},
			{Key: "duration_ms", Value: 300}}
		out := bson.D{}
		for _, field := range doc {
			if field.Key != key {
				out = append(out, field)
			} else if value != nil {
				out = append(out, bson.E{Key: key, Value: value})
			}
		}
		return out
	}
	wrong := []struct {
		label string
		doc   bson.D
	}{
		{"статус 700", event("status", 700)},
		{"уровень fatal", event("level", "fatal")},
		{"без времени", event("ts", nil)},
	}
	for _, w := range wrong {
		_, err := audit.InsertOne(ctx, w.doc)
		var we mongo.WriteException
		if !errors.As(err, &we) {
			fmt.Printf("  %s записано\n", pad(w.label, 14))
			continue
		}
		var info struct {
			Details struct {
				Rules []schemaRule `bson:"schemaRulesNotSatisfied"`
			} `bson:"details"`
		}
		bson.Unmarshal(we.WriteErrors[0].Details, &info)
		reasons := []string{}
		for _, r := range info.Details.Rules {
			if r.OperatorName == "required" {
				for _, name := range r.MissingProperties {
					reasons = append(reasons, "нет "+name)
				}
			}
			if r.OperatorName == "properties" {
				for _, prop := range r.PropertiesNotSatisfied {
					for _, d := range prop.Details {
						reasons = append(reasons, prop.PropertyName+": "+d.OperatorName)
					}
				}
			}
		}
		fmt.Printf("  %s отклонено, код %d: %s\n", pad(w.label, 14), we.WriteErrors[0].Code, strings.Join(reasons, ", "))
	}
	n, _ = audit.CountDocuments(ctx, bson.D{})
	fmt.Printf("  событий в копии: %d\n", n)

	// ── 4. Запросы дежурного без индексов ─────────────────────────────────
	title("4. Запросы дежурного без индексов")
	report(sandbox)

	// ── 5. Индексы по правилу ESR ─────────────────────────────────────────
	title("5. Индексы: равенство, сортировка, диапазон")

	for _, keys := range []bson.D{
		{{Key: "service", Value: 1}, {Key: "ts", Value: -1}, {Key: "status", Value: 1}},
		{{Key: "service", Value: 1}, {Key: "duration_ms", Value: -1}},
		{{Key: "user_id", Value: 1}, {Key: "ts", Value: -1}},
	} {
		name, _ := audit.Indexes().CreateOne(ctx, mongo.IndexModel{Keys: keys})
		fmt.Println("  создан " + name)
	}

	// ── 6. Те же запросы с индексами ──────────────────────────────────────
	title("6. Запросы дежурного с индексами")
	report(sandbox)

	// ── 7. Пять последних ошибок платежей ─────────────────────────────────
	title("7. Пять последних ошибок платежей")

	filtr := queries[0].find[0].Value // фильтр первого запроса: первое поле его команды find
	last, _ := audit.Find(ctx, filtr, options.Find().SetSort(bson.D{{Key: "ts", Value: -1}}).SetLimit(5))
	for last.Next(ctx) {
		doc := last.Current
		ts := doc.Lookup("ts").Time().UTC()
		fmt.Printf("  %s  %s %d  %5d мс\n", ts.Format("02.01 15:04:05"), pad(doc.Lookup("route").StringValue(), 18),
			doc.Lookup("status").AsInt64(), doc.Lookup("duration_ms").AsInt64())
	}
}
Ruby · mongoскрипт целиком
# Аудит журнала сервиса: что умеют правила, индексы и explain из модуля 3.
#
# Скрипт работает с копией журнала logs.events в песочнице: проверяет события
# правилом $jsonSchema и включает его как валидатор, снимает планы трёх
# запросов дежурного, строит под них индексы по правилу ESR и сравнивает
# планы до и после. База logs остаётся без изменений.
#
#     cd scripts
#     ruby logs_audit.rb
#
#     docker compose run --rm ruby scripts/logs_audit.rb    # в контейнере

require "mongo"

Mongo::Logger.logger.level = Logger::WARN

client = Mongo::Client.new(ENV.fetch("MONGO_URI", "mongodb://localhost:27017/"))
events = client.use("logs").database[:events]
sandbox = client.use("sandbox").database
audit = sandbox[:audit]

def title(text)
  puts "\n" + text
  puts "─" * text.length
end

# План и счётчики explain: стадии сверху вниз, вернул, ключей, документов.
def summary(sandbox, find)
  info = sandbox.command(explain: { find: "audit" }.merge(find), verbosity: "executionStats").first
  stage = info["queryPlanner"]["winningPlan"]
  stage = stage["queryPlan"] || stage
  steps = []
  while stage
    steps << stage["stage"] + (stage["indexName"] ? " " + stage["indexName"] : "")
    stage = stage["inputStage"]
  end
  stats = info["executionStats"]
  [steps.join(" → "), stats["nReturned"], stats["totalKeysExamined"], stats["totalDocsExamined"]]
end

# ── 1. Копия журнала ──────────────────────────────────────────────────────
title("1. Копия журнала в песочнице")

audit.drop
audit.insert_many(events.find.to_a)
puts "  событий в копии: #{audit.count_documents({})}"
puts "  индексов: #{audit.indexes.count}"

# ── 2. Правило для событий ────────────────────────────────────────────────
title("2. Правило для событий")

RULE = {
  "bsonType" => "object",
  "required" => ["ts", "service", "level", "route", "status", "duration_ms"],
  "properties" => {
    "ts" => { "bsonType" => "date" },
    "service" => { "enum" => ["api-gateway", "notifier", "orders", "payments", "search"] },
    "level" => { "enum" => ["info", "warn", "error"] },
    "route" => { "bsonType" => "string" },
    "status" => { "bsonType" => "int", "minimum" => 100, "maximum" => 599 },
    "duration_ms" => { "bsonType" => "number", "minimum" => 0 },
  },
}.freeze
puts "  соответствуют правилу:    #{audit.count_documents({ "$jsonSchema" => RULE })}"
puts "  не соответствуют правилу: #{audit.count_documents({ "$nor" => [{ "$jsonSchema" => RULE }] })}"

sandbox.command(collMod: "audit", validator: { "$jsonSchema" => RULE },
                validationLevel: "strict", validationAction: "error")
puts "  правило включено: strict, error"

# ── 3. Проверка правила на записи ─────────────────────────────────────────
title("3. Неверные события")

good = { "ts" => Time.utc(2026, 9, 2, 18, 30), "service" => "payments", "level" => "error",
         "route" => "/api/pay", "status" => 500, "duration_ms" => 300 }
wrong = [
  ["статус 700", good.merge("status" => 700)],
  ["уровень fatal", good.merge("level" => "fatal")],
  ["без времени", good.reject { |key, _| key == "ts" }],
]
wrong.each do |label, doc|
  begin
    audit.insert_one(doc)
    puts "  #{label.ljust(14)} записано"
  rescue Mongo::Error::OperationFailure => e
    reasons = []
    e.details["details"]["schemaRulesNotSatisfied"].each do |rule|
      if rule["operatorName"] == "required"
        reasons += rule["missingProperties"].map { |name| "нет #{name}" }
      end
      if rule["operatorName"] == "properties"
        rule["propertiesNotSatisfied"].each do |prop|
          reasons += prop["details"].map { |d| "#{prop["propertyName"]}: #{d["operatorName"]}" }
        end
      end
    end
    puts "  #{label.ljust(14)} отклонено, код #{e.code}: #{reasons.join(", ")}"
  end
end
puts "  событий в копии: #{audit.count_documents({})}"

# ── 4. Запросы дежурного без индексов ─────────────────────────────────────
QUERIES = [
  ["ошибки платежей", { filter: { "service" => "payments", "status" => { "$gte" => 500 } },
                        sort: { "ts" => -1 }, limit: 5 }],
  ["медленный поиск", { filter: { "service" => "search", "duration_ms" => { "$gte" => 1000 } },
                        sort: { "duration_ms" => -1 }, limit: 5 }],
  ["события покупателя", { filter: { "user_id" => "c-015" }, sort: { "ts" => -1 } }],
].freeze

def report(sandbox)
  puts "  #{"запрос".ljust(20)} #{"вернул".rjust(6)} #{"ключей".rjust(7)} #{"документов".rjust(11)}"
  QUERIES.each do |name, find|
    plan, returned, keys, docs = summary(sandbox, find)
    puts "  #{name.ljust(20)} #{returned.to_s.rjust(6)} #{keys.to_s.rjust(7)} #{docs.to_s.rjust(11)}"
    puts "      #{plan}"
  end
end

title("4. Запросы дежурного без индексов")
report(sandbox)

# ── 5. Индексы по правилу ESR ─────────────────────────────────────────────
title("5. Индексы: равенство, сортировка, диапазон")

[{ "service" => 1, "ts" => -1, "status" => 1 },
 { "service" => 1, "duration_ms" => -1 },
 { "user_id" => 1, "ts" => -1 }].each do |keys|
  audit.indexes.create_one(keys)
  puts "  создан #{audit.indexes.get(keys)["name"]}"
end

# ── 6. Те же запросы с индексами ──────────────────────────────────────────
title("6. Запросы дежурного с индексами")
report(sandbox)

# ── 7. Пять последних ошибок платежей ─────────────────────────────────────
title("7. Пять последних ошибок платежей")

_, find = QUERIES[0]
audit.find(find[:filter]).sort({ "ts" => -1 }).limit(find[:limit]).each do |doc|
  puts "  #{doc["ts"].utc.strftime("%d.%m %H:%M:%S")}  #{doc["route"].ljust(18)} #{doc["status"]}  " \
       "#{doc["duration_ms"].to_s.rjust(5)} мс"
end

client.close
Вывод в терминалрезультат запуска
1. Копия журнала в песочнице
────────────────────────────
  событий в копии: 1200
  индексов: 1

2. Правило для событий
──────────────────────
  соответствуют правилу:    1200
  не соответствуют правилу: 0
  правило включено: strict, error

3. Неверные события
───────────────────
  статус 700     отклонено, код 121: status: maximum
  уровень fatal  отклонено, код 121: level: enum
  без времени    отклонено, код 121: нет ts
  событий в копии: 1200

4. Запросы дежурного без индексов
─────────────────────────────────
  запрос               вернул  ключей  документов
  ошибки платежей           5       0        1200
      SORT → COLLSCAN
  медленный поиск           5       0        1200
      SORT → COLLSCAN
  события покупателя       32       0        1200
      SORT → COLLSCAN

5. Индексы: равенство, сортировка, диапазон
───────────────────────────────────────────
  создан service_1_ts_-1_status_1
  создан service_1_duration_ms_-1
  создан user_id_1_ts_-1

6. Запросы дежурного с индексами
────────────────────────────────
  запрос               вернул  ключей  документов
  ошибки платежей           5      18           5
      LIMIT → FETCH → IXSCAN service_1_ts_-1_status_1
  медленный поиск           5       5           5
      LIMIT → FETCH → IXSCAN service_1_duration_ms_-1
  события покупателя       32      32          32
      FETCH → IXSCAN user_id_1_ts_-1

7. Пять последних ошибок платежей
─────────────────────────────────
  02.09 18:13:22  /api/orders        500    167 мс
  02.09 18:07:16  /api/orders        503   1185 мс
  02.09 17:21:47  /api/orders        500   2043 мс
  02.09 17:04:34  /api/products      500    952 мс
  02.09 14:53:42  /api/login         503   1288 мс
Вывод в терминалрезультат запуска
1. Копия журнала в песочнице
────────────────────────────
  событий в копии: 1200
  индексов: 1

2. Правило для событий
──────────────────────
  соответствуют правилу:    1200
  не соответствуют правилу: 0
  правило включено: strict, error

3. Неверные события
───────────────────
  статус 700     отклонено, код 121: status: maximum
  уровень fatal  отклонено, код 121: level: enum
  без времени    отклонено, код 121: нет ts
  событий в копии: 1200

4. Запросы дежурного без индексов
─────────────────────────────────
  запрос               вернул  ключей  документов
  ошибки платежей           5       0        1200
      SORT → COLLSCAN
  медленный поиск           5       0        1200
      SORT → COLLSCAN
  события покупателя       32       0        1200
      SORT → COLLSCAN

5. Индексы: равенство, сортировка, диапазон
───────────────────────────────────────────
  создан service_1_ts_-1_status_1
  создан service_1_duration_ms_-1
  создан user_id_1_ts_-1

6. Запросы дежурного с индексами
────────────────────────────────
  запрос               вернул  ключей  документов
  ошибки платежей           5      18           5
      LIMIT → FETCH → IXSCAN service_1_ts_-1_status_1
  медленный поиск           5       5           5
      LIMIT → FETCH → IXSCAN service_1_duration_ms_-1
  события покупателя       32      32          32
      FETCH → IXSCAN user_id_1_ts_-1

7. Пять последних ошибок платежей
─────────────────────────────────
  02.09 18:13:22  /api/orders        500    167 мс
  02.09 18:07:16  /api/orders        503   1185 мс
  02.09 17:21:47  /api/orders        500   2043 мс
  02.09 17:04:34  /api/products      500    952 мс
  02.09 14:53:42  /api/login         503   1288 мс
Вывод в терминалрезультат запуска
1. Копия журнала в песочнице
────────────────────────────
  событий в копии: 1200
  индексов: 1

2. Правило для событий
──────────────────────
  соответствуют правилу:    1200
  не соответствуют правилу: 0
  правило включено: strict, error

3. Неверные события
───────────────────
  статус 700     отклонено, код 121: status: maximum
  уровень fatal  отклонено, код 121: level: enum
  без времени    отклонено, код 121: нет ts
  событий в копии: 1200

4. Запросы дежурного без индексов
─────────────────────────────────
  запрос               вернул  ключей  документов
  ошибки платежей           5       0        1200
      SORT → COLLSCAN
  медленный поиск           5       0        1200
      SORT → COLLSCAN
  события покупателя       32       0        1200
      SORT → COLLSCAN

5. Индексы: равенство, сортировка, диапазон
───────────────────────────────────────────
  создан service_1_ts_-1_status_1
  создан service_1_duration_ms_-1
  создан user_id_1_ts_-1

6. Запросы дежурного с индексами
────────────────────────────────
  запрос               вернул  ключей  документов
  ошибки платежей           5      18           5
      LIMIT → FETCH → IXSCAN service_1_ts_-1_status_1
  медленный поиск           5       5           5
      LIMIT → FETCH → IXSCAN service_1_duration_ms_-1
  события покупателя       32      32          32
      FETCH → IXSCAN user_id_1_ts_-1

7. Пять последних ошибок платежей
─────────────────────────────────
  02.09 18:13:22  /api/orders        500    167 мс
  02.09 18:07:16  /api/orders        503   1185 мс
  02.09 17:21:47  /api/orders        500   2043 мс
  02.09 17:04:34  /api/products      500    952 мс
  02.09 14:53:42  /api/login         503   1288 мс
Вывод в терминалрезультат запуска
1. Копия журнала в песочнице
────────────────────────────
  событий в копии: 1200
  индексов: 1

2. Правило для событий
──────────────────────
  соответствуют правилу:    1200
  не соответствуют правилу: 0
  правило включено: strict, error

3. Неверные события
───────────────────
  статус 700     отклонено, код 121: status: maximum
  уровень fatal  отклонено, код 121: level: enum
  без времени    отклонено, код 121: нет ts
  событий в копии: 1200

4. Запросы дежурного без индексов
─────────────────────────────────
  запрос               вернул  ключей  документов
  ошибки платежей           5       0        1200
      SORT → COLLSCAN
  медленный поиск           5       0        1200
      SORT → COLLSCAN
  события покупателя       32       0        1200
      SORT → COLLSCAN

5. Индексы: равенство, сортировка, диапазон
───────────────────────────────────────────
  создан service_1_ts_-1_status_1
  создан service_1_duration_ms_-1
  создан user_id_1_ts_-1

6. Запросы дежурного с индексами
────────────────────────────────
  запрос               вернул  ключей  документов
  ошибки платежей           5      18           5
      LIMIT → FETCH → IXSCAN service_1_ts_-1_status_1
  медленный поиск           5       5           5
      LIMIT → FETCH → IXSCAN service_1_duration_ms_-1
  события покупателя       32      32          32
      FETCH → IXSCAN user_id_1_ts_-1

7. Пять последних ошибок платежей
─────────────────────────────────
  02.09 18:13:22  /api/orders        500    167 мс
  02.09 18:07:16  /api/orders        503   1185 мс
  02.09 17:21:47  /api/orders        500   2043 мс
  02.09 17:04:34  /api/products      500    952 мс
  02.09 14:53:42  /api/login         503   1288 мс

§10Как запустить

Скрипт лежит в репозитории практик, в папке scripts. Он пересоздаёт коллекцию sandbox.audit, поэтому запускать его можно повторно, и вывод будет тем же.

Терминал · из корня репозиториязапуск на своём сервере
python3 scripts/logs_audit.py
ruby scripts/logs_audit.rb
go run scripts/logs_audit.go
g++ -std=c++17 scripts/logs_audit.cpp -o audit $(pkg-config --cflags --libs libmongocxx1) && ./audit
Терминал · из корня репозиториязапуск в контейнере
docker compose run --rm python scripts/logs_audit.py
docker compose run --rm ruby   scripts/logs_audit.rb
docker compose run --rm go     scripts/logs_audit.go
docker compose run --rm cpp    scripts/logs_audit.cpp

Чтобы проверить свой запрос, его добавляют в список запросов дежурного: название и части команды find. Отчёт покажет его план и счётчики до индексов и после.

§11Частые ошибки

Что написаноЧто происходитКак правильно
Правило включено collMod без проверки данных фильтромСтарые неверные события остаются, а при strict любое их обновление отклоняетсяСначала посчитать несоответствующие: $nor с $jsonSchema
Отчёт explain снят только после создания индексовСравнивать не с чем: непонятно, что дал индексСнять отчёт тех же запросов до индексов и после
Индекс «равенство, диапазон» для запроса с сортировкой и пределомВ плане остаётся SORT, сервер читает все подходящие документыПорядок по правилу ESR: поле сортировки перед полем диапазона
По индексу на каждый запрос, который когда-либо выполнялсяКаждая запись в журнал обновляет все индексыИндексы — под частые запросы; лишние удалять
Скрипт запущен на рабочей коллекции вместо копииПервый раздел удаляет коллекцию auditРаботать с копией в песочнице, как в скрипте
Что написаноЧто происходитКак правильно
Правило включено collMod без проверки данных фильтромСтарые неверные события остаются, а при strict любое их обновление отклоняетсяСначала посчитать несоответствующие: $nor с $jsonSchema
Отчёт explain снят только после создания индексовСравнивать не с чем: непонятно, что дал индексСнять отчёт тех же запросов до индексов и после
Индекс «равенство, диапазон» для запроса с сортировкой и пределомВ плане остаётся SORT, сервер читает все подходящие документыПорядок по правилу ESR: поле сортировки перед полем диапазона
По индексу на каждый запрос, который когда-либо выполнялсяКаждая запись в журнал обновляет все индексыИндексы — под частые запросы; лишние удалять
Скрипт запущен на рабочей коллекции вместо копииПервый раздел удаляет коллекцию auditРаботать с копией в песочнице, как в скрипте
Что написаноЧто происходитКак правильно
Правило включено collMod без проверки данных фильтромСтарые неверные события остаются, а при strict любое их обновление отклоняетсяСначала посчитать несоответствующие: $nor с $jsonSchema
Отчёт explain снят только после создания индексовСравнивать не с чем: непонятно, что дал индексСнять отчёт тех же запросов до индексов и после
Индекс «равенство, диапазон» для запроса с сортировкой и пределомВ плане остаётся SORT, сервер читает все подходящие документыПорядок по правилу ESR: поле сортировки перед полем диапазона
По индексу на каждый запрос, который когда-либо выполнялсяКаждая запись в журнал обновляет все индексыИндексы — под частые запросы; лишние удалять
Скрипт запущен на рабочей коллекции вместо копииПервый раздел удаляет коллекцию auditРаботать с копией в песочнице, как в скрипте
Что написаноЧто происходитКак правильно
Правило включено collMod без проверки данных фильтромСтарые неверные события остаются, а при strict любое их обновление отклоняетсяСначала посчитать несоответствующие: $nor с $jsonSchema
Отчёт explain снят только после создания индексовСравнивать не с чем: непонятно, что дал индексСнять отчёт тех же запросов до индексов и после
Индекс «равенство, диапазон» для запроса с сортировкой и пределомВ плане остаётся SORT, сервер читает все подходящие документыПорядок по правилу ESR: поле сортировки перед полем диапазона
По индексу на каждый запрос, который когда-либо выполнялсяКаждая запись в журнал обновляет все индексыИндексы — под частые запросы; лишние удалять
Скрипт запущен на рабочей коллекции вместо копииПервый раздел удаляет коллекцию auditРаботать с копией в песочнице, как в скрипте

§12Проверьте себя

Зачем скрипт считает события, не соответствующие правилу, до команды collMod?

Правило, включённое командой collMod, не перепроверяет документы, которые уже лежат в коллекции. Подсчёт фильтром $nor с $jsonSchema показывает, есть ли такие документы, и от этого зависит режим: при нуле правило включают строго, иначе — с moderate и warn.

Почему функция report вызывается дважды?

Чтобы сравнить одни и те же запросы до индексов и после. Отчёт explain снимается с теми же фильтром, сортировкой и пределом, и разница в счётчиках показывает, что дали индексы.

У запроса «ошибки платежей» с индексом прочитано 18 ключей и 5 документов. Откуда разница?

Индекс упорядочен по сервису, времени и статусу. Сервер идёт по записям payments от новых к старым и проверяет статус по записи индекса; из восемнадцати просмотренных записей пять со статусом от 500, и только для них прочитаны документы.

Почему в индексе для запроса «медленный поиск» только два поля?

Сортировка и условие-диапазон в этом запросе — по одному полю duration_ms. По правилу ESR после поля равенства service идёт поле сортировки, и оно же обслуживает диапазон.

Что изменится в выводе, если запустить скрипт второй раз?

Ничего: первый раздел удаляет коллекцию audit вместе с правилом и индексами и создаёт копию заново, а данные журнала и сами запросы не меняются.

§13Шпаргалка

Проверить данные правилом {"$nor": [{"$jsonSchema": RULE}]}
Включить правило collMod: validator, validationLevel: "strict", validationAction: "error"
Отчёт о запросе explain: {find, filter, sort, limit} verbosity: "executionStats"
Три счётчика nReturned totalKeysExamined totalDocsExamined
Индекс по ESR равенство → сортировка → диапазон {service: 1, ts: -1, status: 1}
Проверить данные правилом {"$nor": [{"$jsonSchema": RULE}]}
Включить правило collMod: validator, validationLevel: "strict", validationAction: "error"
Отчёт о запросе explain: {find, filter, sort, limit} verbosity: "executionStats"
Три счётчика nReturned totalKeysExamined totalDocsExamined
Индекс по ESR равенство → сортировка → диапазон {service: 1, ts: -1, status: 1}
Проверить данные правилом {"$nor": [{"$jsonSchema": RULE}]}
Включить правило collMod: validator, validationLevel: "strict", validationAction: "error"
Отчёт о запросе explain: {find, filter, sort, limit} verbosity: "executionStats"
Три счётчика nReturned totalKeysExamined totalDocsExamined
Индекс по ESR равенство → сортировка → диапазон {service: 1, ts: -1, status: 1}
Проверить данные правилом {"$nor": [{"$jsonSchema": RULE}]}
Включить правило collMod: validator, validationLevel: "strict", validationAction: "error"
Отчёт о запросе explain: {find, filter, sort, limit} verbosity: "executionStats"
Три счётчика nReturned totalKeysExamined totalDocsExamined
Индекс по ESR равенство → сортировка → диапазон {service: 1, ts: -1, status: 1}

Итог главы

Скрипт logs_audit проходит порядок, которым правило и индексы вводят на рабочей коллекции: проверить данные правилом в фильтре, включить правило командой collMod, убедиться, что неверные записи отклоняются и причины читаются из errInfo; снять планы частых запросов, построить индексы по правилу ESR и снять планы ещё раз. Результат видно числами: три запроса дежурного вместо 1200 документов читают 5, 5 и 32. На этом разобраны все темы модуля 3: правила на уровне базы, индексы, план запроса, текстовый поиск и геозапросы.

Глава 3.6 · Справочник по MongoDB Автор: Макаров Максим Николаевич