I Logged Every Coworker Request for a Month: Where a System Administrator’s Day Actually Goes

от автора

Short summary

Our office has roughly one hundred employees, but for a long time we had no proper help desk process. Requests arrived through email, corporate chat, phone calls, and random conversations in the hallway. It always felt as if most of the day was spent dealing with servers, networks, backups, and serious incidents.

To check whether that impression was accurate, I logged every support request for one month. I measured active work time, recorded the cause of recurring problems, tracked waiting periods, and counted how often a new request interrupted something already in progress.

Over 22 working days, I recorded 496 contacts. After merging duplicate reports related to the same issue, those contacts became 421 separate support episodes.

The most surprising result was not the total number of requests. It was the damage caused by short five‑minute tasks constantly breaking longer technical work into small, nearly useless fragments.

This article explains how I collected the data, why average resolution time is often misleading, how I automated Windows workstation diagnostics with PowerShell, and how I processed the resulting event log using Python and PostgreSQL.

The month when I decided to measure all the office noise

Our infrastructure is fairly typical for a company with around one hundred employees. We have a Windows domain, Microsoft 365, several file shares, three virtualization hosts, a backup system, VPN access, network equipment, six multifunction printers, eight meeting rooms, and slightly more than one hundred workstations.

Some employees work only from the office. Others occasionally connect from home.

On paper, my job is mostly about infrastructure. Servers, networking, backups, updates, permissions, monitoring, documentation, and security.

In reality, a normal morning could look like this:

I open the backup console and notice a warning. While investigating it, someone sends a message saying the printer is not working. On the way to the printer, another person asks for help connecting a second monitor. Then I remember that a meeting starts in ten minutes and the camera in the conference room is showing the ceiling again.

By lunchtime, I have the feeling that I have been working continuously, but it is difficult to explain what was actually completed.

The server has not been migrated. The documentation is still unfinished. The old script has not been rewritten. At the same time, employees received help, nobody lost a document, and the company continued operating.

The problem is that this kind of work leaves almost no visible result behind.

You have probably seen the same effect. Someone approaches with a request that should take two minutes. Twenty minutes later, the original task has disappeared from your short‑term memory, your desktop is covered with unrelated windows, and an unfinished configuration change is waiting in an editor.

At some point I became curious about how much time support actually consumed and which issues created the biggest workload.

Not based on feelings. Based on events.

I chose one calendar month with 22 working days. Management knew that I was collecting technical statistics. Message contents, employee documents, and email text were not included in the dataset. Names were replaced with identifiers, while workstation names were hashed.

The goal was to measure support work, not to build a small internal surveillance system.

The first problem appeared immediately.

What exactly counts as one request?

A chat message saying that a file cannot be opened, a phone call about the same file, and another message ten minutes later could be counted as three contacts. Technically, however, they describe one support episode.

I therefore separated raw contacts from support episodes.

During the month, I recorded 496 contacts. After duplicate reports were merged, 421 distinct requests remained.

The difference may not look dramatic, but imagine that one shared printer fails and five employees report it. Without deduplication, the report shows five independent printer failures. That gives a completely false picture of the infrastructure.

How do you measure support when half the requests arrive in the hallway?

We did not have a strict rule requiring every employee to create a formal ticket. People used email, corporate chat, phone calls, and the most reliable office communication protocol ever invented: meeting the administrator next to the coffee machine.

I did not try to force the entire company into a new ITSM system overnight. That would have turned the experiment into an implementation project rather than a measurement of existing work.

Instead, I created a small local event log.

Every request received an identifier and several mandatory fields:

  • time of first contact;

  • communication channel;

  • category;

  • request type;

  • affected device or service;

  • whether the request interrupted another task;

  • start of active work;

  • end of active work;

  • waiting reason;

  • result;

  • suspected root cause;

  • whether the issue had happened before.

The categories had to be redesigned several times.

At first, I used broad groups such as computer, network, and software. After a week, it became obvious that this classification was nearly useless.

The software category included Outlook, browsers, VPN clients, accounting software, PDF tools, and scanner drivers. Comparing them did not provide any meaningful information.

Eventually, I switched to a two‑level classification.

The top level described the area:

  • workstation;

  • user account;

  • network;

  • printing;

  • communication tools;

  • server infrastructure;

  • business applications.

The second level described the exact symptom or operation.

For example:

  • user account → password reset;

  • user account → account lockout;

  • network → missing DHCP lease;

  • network → DNS failure;

  • printing → stuck print queue;

  • workstation → system disk full;

  • communication tools → camera failure;

  • business application → database access denied.

The most important decision was to measure calendar resolution time and active administrator time separately.

Suppose an employee sends a request at 10:00. I start looking at it at 10:05, contact an external vendor at 10:15, receive a response at 15:00, and spend another ten minutes applying the fix.

The calendar resolution time is five hours and fifteen minutes.

The active work time is twenty minutes.

If the report only uses the difference between request creation and closure, it appears that the task consumed half the working day. If the report only uses active time, it hides the fact that the employee waited more than five hours.

Both values are useful. They simply answer different questions.

I also recorded context switches.

Whenever a new request forced me to stop working on something else, the new episode received an interruption flag.

That field eventually became almost more useful than the category itself.

During the month, 294 requests interrupted work that had already started. That produced an average of 13.4 context switches per working day.

At the same time, 41 percent of interrupting requests required less than five minutes of active work.

This is where one of the main office support paradoxes appears.

A short task costs almost nothing by itself. It can still be expensive because of what it interrupts.

Why average resolution time usually lies

At the end of the first week, I generated a simple report and saw that the average active time per request was roughly thirteen minutes.

The result looked comforting.

Twenty requests multiplied by thirteen minutes equals a little more than four hours. Based on that number, the workload did not look particularly heavy.

The real day did not feel anything like that.

The time distribution was strongly skewed.

The median request took 6 minutes and 40 seconds.

The 90th percentile was roughly 36 minutes.

Several requests required more than two hours of active work.

Those few long tasks increased the average significantly.

The average value described a request that almost never existed. Most requests were much shorter, while a small number were dramatically longer.

Another problem was parallel work.

Sometimes three requests were open at the same time. Software was installing on one computer, I was waiting for a screenshot from another employee, and I was changing a remote configuration for someone else.

If I simply added the time between the start and finish of every request, I could end up with nine hours of work inside an eight‑hour day.

That is why the journal stored work start and work stop events.

Each request contained one or more activity intervals. Overlapping intervals were checked separately so that the same minute was not counted twice.

The second important metric was time to first useful action.

Not time to first response in chat. Time to actual diagnosis.

It takes a few seconds to tell someone that their request has been received. The request can still remain untouched for three hours.

Formally, the response is fast. Practically, no work has begun.

The third important metric was waiting time.

I divided waiting periods into several causes:

  • waiting for the employee;

  • waiting for a restart or installation;

  • waiting for an external vendor;

  • waiting for approval;

  • waiting for hardware;

  • waiting for a maintenance window.

This revealed that long closure times were not always caused by slow technical support.

Access requests often remained open for hours because no manager had approved them. Workstation incidents were delayed because the employee took the laptop into a meeting. Some vendor tickets stayed open for an entire day even though internal active work took only a few minutes.

At the end of the month, median active time was 6 minutes and 40 seconds.

Median calendar resolution time was 1 hour and 18 minutes.

That is a difference of almost twelve times.

Should support performance be measured only by ticket closure time?

In my opinion, no.

Without additional context, that metric can easily become a way of punishing administrators for delays they cannot control.

Diagnosing a workstation without walking across the entire office

Roughly one third of all requests started in the same way.

The computer is slow.

The application does not start.

The internet is acting strange.

The printer does nothing.

The slowest possible response is to walk to the employee’s desk, ask them to reproduce the issue, and begin opening standard Windows tools manually.

Sometimes physical access is necessary. In many cases, however, the initial technical picture can be collected remotely.

For this experiment, I created a PowerShell script that used WinRM to generate a workstation snapshot.

It checked system uptime, disk space, memory, network configuration, critical services, printer queues, system event log errors, and pending reboot indicators.

The script does not automatically repair the computer.

That was intentional.

Automatic repair without understanding the cause is a good way to turn a small issue into an interesting evening.

PowerShell

[CmdletBinding()]param(    [Parameter(Mandatory = $true)]    [string]$ComputerName,    [string]$OutputDirectory = ".\workstation-snapshots",    [int]$EventLogHours = 12)Set-StrictMode -Version Latest$ErrorActionPreference = "Stop"function ConvertTo-SafeFileName {    param(        [Parameter(Mandatory = $true)]        [string]$Value    )    $invalidCharacters = [System.IO.Path]::GetInvalidFileNameChars()    $result = $Value    foreach ($character in $invalidCharacters) {        $result = $result.Replace($character, "_")    }    return $result}function Invoke-RemoteSnapshot {    param(        [Parameter(Mandatory = $true)]        [string]$Target,        [Parameter(Mandatory = $true)]        [int]$LogHours    )    $scriptBlock = {        param($Hours)        $snapshotTime = Get-Date        $eventStartTime = $snapshotTime.AddHours(-1 * $Hours)        $operatingSystem = Get-CimInstance -ClassName Win32_OperatingSystem        $computerSystem = Get-CimInstance -ClassName Win32_ComputerSystem        $bios = Get-CimInstance -ClassName Win32_BIOS        $systemDisk = Get-CimInstance -ClassName Win32_LogicalDisk `            -Filter "DeviceID='C:'"        $networkAdapters = Get-CimInstance `            -ClassName Win32_NetworkAdapterConfiguration |            Where-Object {                $_.IPEnabled -eq $true            } |            Select-Object `                Description,                MACAddress,                DHCPEnabled,                DHCPServer,                IPAddress,                IPSubnet,                DefaultIPGateway,                DNSServerSearchOrder        $importantServices = @(            "Dnscache",            "Dhcp",            "LanmanWorkstation",            "Spooler",            "W32Time",            "Winmgmt"        )        $services = foreach ($serviceName in $importantServices) {            $service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue            if ($null -eq $service) {                [pscustomobject]@{                    Name      = $serviceName                    Status    = "Missing"                    StartType = $null                }            }            else {                $serviceConfiguration = Get-CimInstance `                    -ClassName Win32_Service `                    -Filter "Name='$serviceName'"                [pscustomobject]@{                    Name      = $service.Name                    Status    = $service.Status.ToString()                    StartType = $serviceConfiguration.StartMode                }            }        }        $topProcesses = Get-Process |            Sort-Object -Property CPU -Descending |            Select-Object -First 15 `                Name,                Id,                CPU,                WorkingSet64,                PrivateMemorySize64,                StartTime        $systemErrors = Get-WinEvent `            -FilterHashtable @{                LogName   = "System"                Level     = 2, 3                StartTime = $eventStartTime            } `            -ErrorAction SilentlyContinue |            Select-Object -First 100 `                TimeCreated,                Id,                ProviderName,                LevelDisplayName,                Message        $applicationErrors = Get-WinEvent `            -FilterHashtable @{                LogName   = "Application"                Level     = 2, 3                StartTime = $eventStartTime            } `            -ErrorAction SilentlyContinue |            Select-Object -First 100 `                TimeCreated,                Id,                ProviderName,                LevelDisplayName,                Message        $pendingRebootReasons = New-Object System.Collections.Generic.List[string]        $componentBasedServicing = Test-Path `            "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending"        if ($componentBasedServicing) {            $pendingRebootReasons.Add("ComponentBasedServicing")        }        $windowsUpdate = Test-Path `            "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired"        if ($windowsUpdate) {            $pendingRebootReasons.Add("WindowsUpdate")        }        $sessionManagerPath = `            "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"        $pendingFileRename = Get-ItemProperty `            -Path $sessionManagerPath `            -Name PendingFileRenameOperations `            -ErrorAction SilentlyContinue        if ($null -ne $pendingFileRename) {            $pendingRebootReasons.Add("PendingFileRenameOperations")        }        $printers = Get-Printer -ErrorAction SilentlyContinue |            Select-Object `                Name,                DriverName,                PortName,                PrinterStatus,                WorkOffline,                Shared        $printJobs = Get-PrintJob -PrinterName * `            -ErrorAction SilentlyContinue |            Select-Object `                PrinterName,                ID,                DocumentName,                JobStatus,                SubmittedTime,                Size        $uptime = $snapshotTime - $operatingSystem.LastBootUpTime        [pscustomobject]@{            SnapshotTime = $snapshotTime            ComputerName = $env:COMPUTERNAME            UserName     = $computerSystem.UserName            Hardware = [pscustomobject]@{                Manufacturer    = $computerSystem.Manufacturer                Model           = $computerSystem.Model                SerialNumber    = $bios.SerialNumber                TotalMemoryGB   = [math]::Round(                    $computerSystem.TotalPhysicalMemory / 1GB,                    2                )            }            OperatingSystem = [pscustomobject]@{                Caption        = $operatingSystem.Caption                Version        = $operatingSystem.Version                BuildNumber    = $operatingSystem.BuildNumber                LastBootUpTime = $operatingSystem.LastBootUpTime                UptimeHours    = [math]::Round(                    $uptime.TotalHours,                    2                )            }            SystemDisk = [pscustomobject]@{                SizeGB = [math]::Round(                    $systemDisk.Size / 1GB,                    2                )                FreeGB = [math]::Round(                    $systemDisk.FreeSpace / 1GB,                    2                )                FreePercent = [math]::Round(                    ($systemDisk.FreeSpace / $systemDisk.Size) * 100,                    2                )            }            Network              = $networkAdapters            Services             = $services            TopProcesses         = $topProcesses            SystemErrors         = $systemErrors            ApplicationErrors    = $applicationErrors            PendingReboot        = $pendingRebootReasons.Count -gt 0            PendingRebootReasons = $pendingRebootReasons            Printers             = $printers            PrintJobs            = $printJobs        }    }    Invoke-Command `        -ComputerName $Target `        -ScriptBlock $scriptBlock `        -ArgumentList $LogHours}try {    if (-not (Test-Path -Path $OutputDirectory)) {        New-Item `            -Path $OutputDirectory `            -ItemType Directory `            -Force |            Out-Null    }    Write-Host "Collecting snapshot from $ComputerName"    $snapshot = Invoke-RemoteSnapshot `        -Target $ComputerName `        -LogHours $EventLogHours    $safeComputerName = ConvertTo-SafeFileName `        -Value $ComputerName    $timestamp = Get-Date -Format "yyyyMMdd-HHmmss"    $outputPath = Join-Path `        -Path $OutputDirectory `        -ChildPath "$safeComputerName-$timestamp.json"    $snapshot |        ConvertTo-Json -Depth 8 |        Set-Content `            -Path $outputPath `            -Encoding UTF8    Write-Host "Snapshot saved to $outputPath"}catch {    Write-Error "Snapshot collection failed: $($_.Exception.Message)"    exit 1}

The script was particularly useful for common workstation complaints.

For example, an employee reports that the computer has become slow. The snapshot shows 26 days of uptime, only 900 megabytes of free space on drive C, and a pending update restart.

There is no need to walk to the desk while pretending the problem is mysterious.

In another case, printing stops. The event log shows that the Spooler service has crashed, while the queue contains a damaged job several hundred megabytes in size.

Instead of reinstalling the driver immediately, the investigation can begin with the exact queue that is causing the issue.

Average initial diagnostic time for workstation requests dropped from roughly nine minutes to around four minutes.

That is not a revolution, but the difference accumulates when several similar requests arrive every day.

What Python showed after the emotions were removed

During the first few days, the journal looked like an ordinary list. It contained requests, times, categories, and comments.

Based on visual inspection, printers seemed to consume the most effort.

That was probably because printers can create the maximum amount of irritation in the minimum amount of time.

After processing the data, the picture changed.

Events were stored in JSON Lines format. Each line represented one action:

  • request creation;

  • work start;

  • work stop;

  • waiting;

  • work resumption;

  • resolution.

This format was more convenient than storing one row per request. With one row, I would have needed to predict a fixed number of work intervals in advance.

The following is a simplified version of the analyzer.

It reconstructs active intervals, detects overlaps, calculates daily workload, generates category distributions, and identifies recurring causes.

Python

from __future__ import annotationsimport jsonfrom dataclasses import dataclassfrom pathlib import Pathfrom typing import Iterableimport pandas as pdEVENT_FILE = Path("support-events.jsonl")OUTPUT_DIRECTORY = Path("support-report")MAX_OPEN_INTERVAL_SECONDS = 4 * 60 * 60@dataclass(frozen=True)class Interval:    ticket_id: str    started_at: pd.Timestamp    finished_at: pd.Timestamp    category: str    root_cause: str    interrupted_work: bool    @property    def duration_seconds(self) -> float:        return max(            0.0,            (self.finished_at - self.started_at).total_seconds(),        )def parse_timestamp(value: str) -> pd.Timestamp:    timestamp = pd.Timestamp(value)    if timestamp.tzinfo is None:        timestamp = timestamp.tz_localize("UTC")    return timestamp.tz_convert("UTC")def load_events(path: Path) -> pd.DataFrame:    rows: list[dict] = []    with path.open("r", encoding="utf-8") as source:        for line_number, line in enumerate(source, start=1):            stripped = line.strip()            if not stripped:                continue            try:                row = json.loads(stripped)            except json.JSONDecodeError as error:                raise ValueError(                    f"Invalid JSON at line {line_number}: {error}"                ) from error            required_fields = {                "ticket_id",                "event_type",                "timestamp",            }            missing_fields = required_fields - row.keys()            if missing_fields:                raise ValueError(                    f"Missing fields at line {line_number}: "                    f"{sorted(missing_fields)}"                )            rows.append(row)    frame = pd.DataFrame(rows)    if frame.empty:        raise ValueError("Event file is empty")    frame["timestamp"] = frame["timestamp"].map(parse_timestamp)    optional_columns = {        "category": "uncategorized",        "root_cause": "unknown",        "interrupted_work": False,        "channel": "unknown",    }    for column, default_value in optional_columns.items():        if column not in frame.columns:            frame[column] = default_value        else:            frame[column] = frame[column].fillna(default_value)    return frame.sort_values(        ["ticket_id", "timestamp"]    ).reset_index(drop=True)def build_intervals(events: pd.DataFrame) -> list[Interval]:    intervals: list[Interval] = []    for ticket_id, ticket_events in events.groupby("ticket_id"):        active_start: pd.Timestamp | None = None        active_metadata: dict | None = None        for row in ticket_events.itertuples(index=False):            event_type = row.event_type            if event_type == "work_started":                if active_start is not None:                    implicit_finish = row.timestamp                    duration = (                        implicit_finish - active_start                    ).total_seconds()                    if 0 < duration <= MAX_OPEN_INTERVAL_SECONDS:                        intervals.append(                            Interval(                                ticket_id=ticket_id,                                started_at=active_start,                                finished_at=implicit_finish,                                category=active_metadata["category"],                                root_cause=active_metadata["root_cause"],                                interrupted_work=active_metadata[                                    "interrupted_work"                                ],                            )                        )                active_start = row.timestamp                active_metadata = {                    "category": row.category,                    "root_cause": row.root_cause,                    "interrupted_work": bool(row.interrupted_work),                }            elif event_type in {                "work_stopped",                "waiting",                "resolved",            }:                if active_start is None or active_metadata is None:                    continue                duration = (                    row.timestamp - active_start                ).total_seconds()                if 0 < duration <= MAX_OPEN_INTERVAL_SECONDS:                    intervals.append(                        Interval(                            ticket_id=ticket_id,                            started_at=active_start,                            finished_at=row.timestamp,                            category=active_metadata["category"],                            root_cause=active_metadata["root_cause"],                            interrupted_work=active_metadata[                                "interrupted_work"                            ],                        )                    )                active_start = None                active_metadata = None    return intervalsdef merge_global_overlaps(    intervals: Iterable[Interval],) -> pd.DataFrame:    rows = [        {            "ticket_id": item.ticket_id,            "started_at": item.started_at,            "finished_at": item.finished_at,            "category": item.category,            "root_cause": item.root_cause,            "interrupted_work": item.interrupted_work,        }        for item in intervals    ]    frame = pd.DataFrame(rows)    if frame.empty:        return frame    frame = frame.sort_values(        ["started_at", "finished_at"]    ).reset_index(drop=True)    boundaries: list[tuple[pd.Timestamp, int]] = []    for row in frame.itertuples(index=False):        boundaries.append((row.started_at, 1))        boundaries.append((row.finished_at, -1))    boundaries.sort(key=lambda item: (item[0], -item[1]))    active_count = 0    previous_time: pd.Timestamp | None = None    unique_seconds = 0.0    for boundary_time, delta in boundaries:        if previous_time is not None and active_count > 0:            unique_seconds += (                boundary_time - previous_time            ).total_seconds()        active_count += delta        previous_time = boundary_time    frame.attrs["unique_active_seconds"] = unique_seconds    frame["duration_seconds"] = (        frame["finished_at"] - frame["started_at"]    ).dt.total_seconds()    return framedef calculate_ticket_metrics(    events: pd.DataFrame,    intervals: pd.DataFrame,) -> pd.DataFrame:    created = (        events[events["event_type"] == "created"]        .groupby("ticket_id")["timestamp"]        .min()        .rename("created_at")    )    resolved = (        events[events["event_type"] == "resolved"]        .groupby("ticket_id")["timestamp"]        .max()        .rename("resolved_at")    )    first_work = (        events[events["event_type"] == "work_started"]        .groupby("ticket_id")["timestamp"]        .min()        .rename("first_work_at")    )    active = (        intervals.groupby("ticket_id")["duration_seconds"]        .sum()        .rename("active_seconds")    )    metadata = (        events.sort_values("timestamp")        .groupby("ticket_id")        .agg(            category=("category", "last"),            root_cause=("root_cause", "last"),            channel=("channel", "first"),            interrupted_work=("interrupted_work", "max"),        )    )    tickets = metadata.join(        [created, resolved, first_work, active],        how="left",    )    tickets["active_seconds"] = tickets[        "active_seconds"    ].fillna(0)    tickets["calendar_seconds"] = (        tickets["resolved_at"] - tickets["created_at"]    ).dt.total_seconds()    tickets["first_action_seconds"] = (        tickets["first_work_at"] - tickets["created_at"]    ).dt.total_seconds()    tickets["created_date"] = (        tickets["created_at"]        .dt.tz_convert("Europe/Riga")        .dt.date    )    return tickets.reset_index()def build_reports(    tickets: pd.DataFrame,    intervals: pd.DataFrame,) -> None:    OUTPUT_DIRECTORY.mkdir(        parents=True,        exist_ok=True,    )    category_report = (        tickets.groupby("category")        .agg(            tickets=("ticket_id", "count"),            active_hours=(                "active_seconds",                lambda value: value.sum() / 3600,            ),            median_active_minutes=(                "active_seconds",                lambda value: value.median() / 60,            ),            p90_active_minutes=(                "active_seconds",                lambda value: value.quantile(0.90) / 60,            ),            interrupted_tickets=(                "interrupted_work",                "sum",            ),        )        .sort_values("active_hours", ascending=False)    )    daily_report = (        tickets.groupby("created_date")        .agg(            tickets=("ticket_id", "count"),            active_hours=(                "active_seconds",                lambda value: value.sum() / 3600,            ),            median_first_action_minutes=(                "first_action_seconds",                lambda value: value.median() / 60,            ),            interruption_count=(                "interrupted_work",                "sum",            ),        )        .sort_index()    )    recurrence_report = (        tickets[tickets["root_cause"] != "unknown"]        .groupby("root_cause")        .agg(            occurrences=("ticket_id", "count"),            total_hours=(                "active_seconds",                lambda value: value.sum() / 3600,            ),            affected_categories=(                "category",                lambda value: ", ".join(                    sorted(set(value))                ),            ),        )        .query("occurrences >= 3")        .sort_values(            ["occurrences", "total_hours"],            ascending=False,        )    )    ticket_report = tickets.copy()    ticket_report["active_minutes"] = (        ticket_report["active_seconds"] / 60    )    ticket_report["calendar_minutes"] = (        ticket_report["calendar_seconds"] / 60    )    ticket_report["first_action_minutes"] = (        ticket_report["first_action_seconds"] / 60    )    ticket_report.to_csv(        OUTPUT_DIRECTORY / "tickets.csv",        index=False,    )    category_report.to_csv(        OUTPUT_DIRECTORY / "categories.csv"    )    daily_report.to_csv(        OUTPUT_DIRECTORY / "daily.csv"    )    recurrence_report.to_csv(        OUTPUT_DIRECTORY / "recurrence.csv"    )    unique_active_hours = (        intervals.attrs.get(            "unique_active_seconds",            0,        )        / 3600    )    print(f"Tickets: {len(tickets)}")    print(        f"Unique active hours: "        f"{unique_active_hours:.2f}"    )    print(        f"Median active minutes: "        f"{tickets['active_seconds'].median() / 60:.2f}"    )    print(        f"P90 active minutes: "        f"{tickets['active_seconds'].quantile(0.90) / 60:.2f}"    )    print(        f"Interrupted tickets: "        f"{int(tickets['interrupted_work'].sum())}"    )def main() -> None:    events = load_events(EVENT_FILE)    interval_objects = build_intervals(events)    intervals = merge_global_overlaps(interval_objects)    if intervals.empty:        raise ValueError("No active work intervals found")    tickets = calculate_ticket_metrics(        events,        intervals,    )    build_reports(        tickets,        intervals,    )if __name__ == "__main__":    main()

After cleaning the data, I ended up with 421 separate requests.

Of those, 303 were incidents where something had stopped working. Another 118 were service requests involving access, software installation, equipment preparation, configuration changes, or onboarding.

Total active support time was slightly more than 96 hours.

That works out to roughly 4 hours and 22 minutes per working day.

At first glance, it looks as if half the day remained free.

In reality, the rest of the day included planned infrastructure work, monitoring, server maintenance, vendor communication, purchasing, documentation, and repeated attempts to remember what I had been doing before the latest message arrived.

Where the working day disappeared

The largest group by request count was related to user accounts and office applications.

Password resets, lockouts, file share permissions, email problems, damaged profiles, authentication failures after phone replacements, and missing application access appeared constantly.

The number of requests was high, but individual cases were usually resolved quickly.

Network and VPN issues consumed more active time.

There were fewer of them, but diagnosis took longer. Especially when a problem happened to one user, only from a home network, only in the evening, and disappeared as soon as testing began.

Printing accounted for around 12 percent of active support time.

I expected more.

The subjective impression was probably distorted by the nature of printer failures. They appear suddenly, affect several people at once, and often require physical intervention.

Servers, backups, and monitoring created only around 6 percent of user support requests.

That does not mean server work occupied only 6 percent of my day.

Most server maintenance was planned work and was not included in the user support log.

The analysis of short tasks produced the most useful result.

A total of 172 requests required less than five minutes of active work. Together, they consumed roughly 12 percent of active support time.

At the same time, they caused more than half of all context switches.

This creates a strange situation.

In terms of direct labor, the task is nearly free.

In terms of its effect on the day, it is not.

During the month, I also recorded uninterrupted periods of technical work. A long period was defined as at least 45 minutes without calls, messages, or switching to another request.

Across 22 working days, there were only eleven such periods.

Eleven usable periods of concentration in an entire month.

Not in one week.

Someone may ask why I did not simply disable chat notifications.

The reason is that some requests really were urgent.

If a shared resource fails or the accounting team cannot connect to a database, replying three hours later would be difficult to explain.

The problem was not the notifications themselves. The problem was the lack of separation between ordinary and urgent communication.

A message about an inconveniently placed desktop shortcut looked exactly the same as a message saying that an entire department had lost access to a shared folder.

After the experiment, we introduced a simple rule.

Urgent incidents use one dedicated channel. Normal requests go through a form or email.

A request is considered urgent when it:

  • stops several employees from working;

  • blocks a critical business process;

  • affects a shared service;

  • creates a security risk.

At first, I expected employees to mark everything as urgent.

That barely happened.

When criteria are written in normal human language, most people are perfectly capable of using them correctly.

Recurring issues that should not be fixed manually forever

During the third week, I noticed that some causes appeared suspiciously often.

Not categories. Actual root causes.

A stuck print queue may appear on several computers but originate from the same driver. VPN problems may look different while being caused by one client update. Account lockouts may come from an old password saved on a mobile device.

I added a root cause field for recurring problems.

The value described what had to be corrected rather than what the user observed.

A weak root cause description looks like this:

  • email does not work;

  • no internet;

  • printer does not print.

A more useful description looks like this:

  • corrupted local Outlook profile;

  • static DNS configuration left after testing;

  • printer driver creates a stuck job;

  • old password stored in a mobile client;

  • system disk filled by update cache;

  • VPN client failed to recreate its virtual adapter.

The resulting dataset was loaded into PostgreSQL.

The following query identifies recurring causes, calculates total effort, measures interruption impact, and estimates the potential benefit of eliminating the source.

The query assumes that the support_episode table already contains final request metrics.

SQL, PostgreSQL

WITH normalized AS (    SELECT        ticket_id,        created_at,        resolved_at,        category,        root_cause,        active_seconds,        interrupted_work,        affected_users,        CASE            WHEN root_cause IS NULL                OR btrim(root_cause) = ''            THEN 'unknown'            ELSE lower(btrim(root_cause))        END AS normalized_root_cause    FROM support_episode    WHERE created_at >= date_trunc(        'month',        current_date    ) - interval '1 month'      AND created_at < date_trunc(        'month',        current_date    )),cause_statistics AS (    SELECT        normalized_root_cause,        count(*) AS episode_count,        count(DISTINCT category) AS category_count,        sum(active_seconds) AS total_active_seconds,        percentile_cont(0.5)            WITHIN GROUP (                ORDER BY active_seconds            ) AS median_active_seconds,        percentile_cont(0.9)            WITHIN GROUP (                ORDER BY active_seconds            ) AS p90_active_seconds,        sum(            CASE                WHEN interrupted_work                THEN 1                ELSE 0            END        ) AS interruption_count,        sum(            greatest(                coalesce(affected_users, 1),                1            )        ) AS total_affected_users,        min(created_at) AS first_seen_at,        max(created_at) AS last_seen_at    FROM normalized    WHERE normalized_root_cause <> 'unknown'    GROUP BY normalized_root_cause),repeatability AS (    SELECT        *,        extract(            epoch FROM (                last_seen_at - first_seen_at            )        ) / 86400.0 AS observation_span_days,        total_active_seconds            / nullif(episode_count, 0)            AS average_active_seconds,        interruption_count::numeric            / nullif(episode_count, 0)            AS interruption_ratio    FROM cause_statistics    WHERE episode_count >= 3),ranked AS (    SELECT        *,        dense_rank() OVER (            ORDER BY                total_active_seconds DESC        ) AS rank_by_time,        dense_rank() OVER (            ORDER BY                episode_count DESC        ) AS rank_by_frequency,        dense_rank() OVER (            ORDER BY                total_affected_users DESC        ) AS rank_by_impact    FROM repeatability),scored AS (    SELECT        *,        (            ln(episode_count + 1) * 2.0            +            ln(                total_active_seconds / 60.0                + 1            ) * 1.5            +            ln(total_affected_users + 1)            +            interruption_ratio * 3.0        ) AS remediation_score,        (            total_active_seconds            * 0.70        ) AS estimated_avoidable_seconds    FROM ranked)SELECT    normalized_root_cause AS root_cause,    episode_count,    round(        total_active_seconds / 3600.0,        2    ) AS total_active_hours,    round(        median_active_seconds / 60.0,        2    ) AS median_active_minutes,    round(        p90_active_seconds / 60.0,        2    ) AS p90_active_minutes,    interruption_count,    total_affected_users,    round(        observation_span_days::numeric,        1    ) AS observation_span_days,    rank_by_time,    rank_by_frequency,    rank_by_impact,    round(        remediation_score::numeric,        2    ) AS remediation_score,    round(        estimated_avoidable_seconds            / 3600.0,        2    ) AS estimated_avoidable_hoursFROM scoredORDER BY    remediation_score DESC,    total_active_seconds DESC,    episode_count DESC;

The coefficients in remediation_score are not intended to be scientifically perfect.

They simply move causes to the top when they happen frequently, consume time, affect multiple employees, and interrupt other work.

Eleven recurring root causes generated 29 percent of all support requests.

Handling them required slightly more than 24 hours of active work.

The most expensive cause was not a server outage.

It was an unfortunate combination of an old firmware version and a problematic printer driver on two multifunction devices.

That combination generated 19 requests and several incidents where the shared print queue became stuck.

The second most expensive cause involved VPN failures after an automatic client update.

The third involved system disks filled with update caches, temporary files, and old user profiles.

This was a useful moment.

When requests are viewed individually, each one looks like a small random incident.

When they are grouped by root cause, randomness becomes technical debt.

What I automated and what I decided to leave to humans

After seeing the results, my first reaction was to automate everything.

This is a dangerous stage.

A system administrator sees a repetitive action and immediately imagines a script that runs at night and repairs the world without asking anyone.

After a couple of days, the enthusiasm became more controlled.

Not every frequent action should be automated.

A good automation candidate should be:

  • repetitive;

  • easy to verify;

  • predictable;

  • reversible;

  • safe when it fails.

If the consequences of an incorrect script are worse than the original problem, manual work may still be the better option.

The first things I automated were diagnostic collection, disk space checks, pending reboot detection, safe temporary file cleanup, and print service monitoring.

Access management was handled more carefully.

Adding a user to a directory group takes one command.

The difficult part is not the command. The difficult part is deciding whether that user should have access.

Automating the technical action without automating approval only makes incorrect changes happen faster.

For onboarding, I created a reusable template.

It handled account creation, license assignment, mandatory multifactor authentication, standard group membership, and checklist generation.

Non‑standard permissions still required separate review.

For workstations, I configured daily collection of several health indicators:

  • free space on the system disk;

  • time since the last restart;

  • antivirus status;

  • backup agent status;

  • disk errors;

  • pending reboot state;

  • VPN client version;

  • status of critical services.

One week after enabling the checks, I found five computers with less than five gigabytes of free space remaining.

The employees had not reported any problems yet.

The issue was corrected before it became a support request.

That is an important difference.

Useful automation does not only close tickets faster. It prevents some tickets from appearing at all.

There were also several things I deliberately did not automate.

Meeting room problems are one example.

It is possible to monitor device availability, firmware versions, network connectivity, and USB status. None of that helps when someone moves a cable to another port one minute before a meeting.

I also avoided automatically restarting services after every error.

Sometimes a service crashes because of a temporary failure. Sometimes the crash is the only visible sign of a deeper problem.

If automation constantly restarts the service without collecting diagnostic information, the system appears healthy while the cause remains hidden.

Would you restart a service automatically after the first failure, or would you capture a dump and collect context first?

There is no universal answer.

It depends on service criticality, recovery requirements, and whether the failure can be reproduced.

What changed after the experiment

The main result was not the report and not the scripts.

The support model itself changed.

Previously, every request looked like an isolated request from one specific person.

After the analysis, it became an event inside a larger system.

That event had:

  • a channel;

  • a cause;

  • an interruption cost;

  • a recurrence pattern;

  • a relationship with other incidents.

We introduced a central request log.

It did not become a huge ITSM platform with twenty mandatory fields and a form nobody wanted to complete.

Employees received a simple form, an email address, and a separate channel for urgent incidents.

The main improvement was that requests stopped disappearing inside private messages.

We also introduced two protected periods for concentrated work.

One was in the morning. The other was after lunch.

During those periods, I closed the normal corporate chat for roughly one hour. Urgent incidents could still arrive through the dedicated channel.

The first week felt uncomfortable.

It seemed likely that somewhere a printer was already burning while I was irresponsibly reading documentation.

Then it became clear that most requests could wait for one hour without any serious consequences.

The number of uninterrupted concentration periods increased from eleven per month to four or five per week.

Several tasks that had been postponed repeatedly were finally completed:

  • updating the network diagram;

  • testing backup restoration;

  • rewriting VPN documentation;

  • reviewing old administrative permissions.

Recurring root causes became a separate technical debt backlog.

When the same cause appeared three times, it was reviewed.

When it appeared five times, it required either remediation or a documented reason explaining why remediation was currently impossible.

Not every recurring problem can be fixed immediately.

Sometimes hardware must be replaced. Sometimes an external vendor must change something. Sometimes the only available option is waiting for a maintenance window.

Even then, it is useful to know the cost of waiting.

One month after updating printer firmware and replacing the problematic drivers, request volume related to those two multifunction devices dropped by almost three times.

After VPN client versions were standardized, most strange virtual adapter failures disappeared.

Proactive disk checks began detecting low‑space conditions before users noticed performance problems.

The total number of support requests did not fall to zero.

That would be impossible.

Employees join, leave, replace devices, forget passwords, work from hotels, and discover new ways to connect an HDMI cable incorrectly.

Support simply became more predictable.

The experiment also revealed an organizational issue.

Some requests were not technical problems at all.

Employees did not know who had to approve access, where an instruction was stored, or which application they were expected to use.

The system administrator was simply the easiest available person to ask.

After publishing short instructions and a list of system owners, these requests became less frequent.

Not because everyone suddenly developed a passion for documentation.

A link to a clear page simply works better than a 47-page document last updated two years ago.

A few conclusions that now seem obvious

Active work time, calendar resolution time, and waiting time should be measured separately.

One value cannot describe support work.

Average time is almost meaningless without the median and percentiles.

A few long incidents can distort the entire dataset.

The number of tickets also says very little by itself.

Twenty password resets and one unstable network segment may require the same active time, but they demand completely different solutions.

Short tasks are not free.

Their main cost often comes from context switching rather than direct labor.

Recurring requests should be connected to root causes.

Otherwise, one defect can survive for months while appearing to be a collection of unrelated minor incidents.

Not every operation should be automated.

In many cases, the safest first step is automating data collection and condition checking. Corrective actions should remain controlled and reversible.

A system administrator in a small office rarely works only with technology.

A significant part of the job exists somewhere between people, processes, and infrastructure.

A poor access approval process becomes a technical ticket.

An unclear instruction becomes a phone call.

A service without an owner makes the administrator the owner by default.

One month of logging did not create a perfect model.

I probably forgot to record some requests. Sometimes a timer remained active longer than it should have. Categories changed during the experiment. The act of measurement also affected behavior.

When you know that another five‑minute interruption has to be logged, you start paying more attention to why it happened.

Even imperfect data was more useful than intuition.

Before the experiment, it felt as if the day disappeared because of too many complicated problems.

The data showed something else.

There were not many genuinely complex incidents.

The day disappeared inside the gaps between small tasks, waiting periods, repeated diagnostics, and attempts to remember what had been open twenty minutes earlier.

Now, when someone stops me in the hallway and asks for help with one small thing, I still often deal with it immediately.

We work in the same office, after all. This is not a laboratory for ideal ITSM practices.

But the request still goes into the journal.

One small thing really changes nothing.

Four hundred and twenty‑one small things in one month already look a lot like infrastructure.

ссылка на оригинал статьи https://habr.com/ru/articles/1063390/