Aplicación de scripts PowerShell — automatizar de forma segura la investigación de logs, el archivado y la generación de informes

· Actualizado el: · · PowerShell, Windows, Automatización, Investigación de logs, Mejora operativa, Aprovechamiento de activos existentes

Historial de revisiones (1 actualizaciones, última el 22 Aug 2026)

Registro de los cambios realizados en este artículo. Cuando se archivó una versión previa, sigue siendo legible mediante un enlace permanente con DOI.

Se ha sustituido la traducción, que estaba abreviada, por una traducción completa del artículo japonés en su versión actual: el texto crece un 57 %. Se han incorporado 5 apartados, 17 filas de tabla y 5 bloques de código que no estaban en la edición anterior. El contenido no cambia respecto al original japonés; esta edición simplemente ya no lo resume. Además, los enlaces a otros artículos que ya tienen edición en español apuntan ahora a esa edición en lugar de a la japonesa. Leer la versión anterior a esta actualización (DOI: 10.5281/zenodo.21638207)
Primera publicación
Citar este artículo(DOI: 10.5281/zenodo.21638206)

Este artículo está archivado en Zenodo. A continuación se muestran tanto el DOI que siempre resuelve a la última versión como el DOI fijado a la versión que está leyendo.

Go Komura (2026). Aplicación de scripts PowerShell — automatizar de forma segura la investigación de logs, el archivado y la generación de informes. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21638206 https://comcomponent.com/es/blog/2026/06/02/000-powershell-script-log-maintenance-automation/

DOI (última versión)
10.5281/zenodo.21638206
DOI (esta versión)
10.5281/zenodo.22053412

1. Lo primero que conviene tener claro

En el artículo anterior organizamos los comandos básicos de PowerShell, las canalizaciones (pipelines), la salida en CSV, JSON, los scripts .ps1 y la comprobación segura mediante -WhatIf. En esta continuación, vamos a crear un script algo más grande y práctico para el trabajo real. El tema son las siguientes tareas operativas:

  1. Investigar los logs
  2. Recopilar las líneas de error en un CSV
  3. Listar los logs antiguos como candidatos a archivado
  4. Mover los logs antiguos cuando sea necesario
  5. Dejar constancia del resultado de la ejecución como evidencia

Lo importante en la aplicación práctica de PowerShell no es alargar los comandos, sino construir el siguiente flujo:

  1. Separar la configuración
  2. Construir el procesamiento de lectura
  3. Dejar constancia de la salida
  4. Separar el procesamiento de cambios en una función
  5. Hacer un ensayo con -WhatIf
  6. Por último, considerar la ejecución automática

Automatizar no significa «eliminar la verificación humana». Significa fijar los puntos que se deben comprobar, de modo que cada ejecución deje siempre la misma clase de evidencia.

El código que aparece en este artículo está publicado en GitHub como un conjunto de muestras que se puede ejecutar tal cual (el script completo, el archivo de configuración, un script para crear un entorno de logs ficticios y pruebas Pester que verifican la investigación, el ensayo y el movimiento).

powershell-script-log-maintenance-automation - komurasoft-blog-samples (GitHub)

El lugar de este artículo en la serie

Los artículos sobre PowerShell forman una serie de 3 entregas. No es una serie numerada de forma oficial, pero leerlos en este orden mantiene la continuidad.

Orden Artículo Contenido tratado
1 Comandos básicos de PowerShell — las operaciones que hay que aprender primero y su uso seguro Cómo buscar comandos, canalizaciones, CSV / JSON, .ps1, -WhatIf
2 Recetario de comandos prácticos de PowerShell — ampliar las funciones pequeñas que se usan a diario Piezas para agregación, comparación, extracción y generación de evidencias
3 Este artículo Reunir las piezas en un único script operativo, hasta la configuración, la evidencia, el ensayo y la ejecución periódica

Como requisito previo, solo hace falta el contenido del artículo 1. Aunque no haya leído el 2, puede seguir este artículo sin problema. La expresión «el artículo anterior» al principio se refiere al artículo 1.

2. Lo que vamos a construir

En este artículo vamos a crear un script llamado Invoke-LogMaintenance.ps1.

Sus funciones principales son las siguientes:

Función Contenido
Búsqueda de logs Busca archivos .log dentro de la carpeta indicada
Selección de periodo Investiga solo los logs actualizados en los últimos N días
Extracción de errores Extrae líneas como ERROR, WARN, FATAL
Salida en CSV Guarda los resultados de búsqueda en log-hits.csv
Listado de logs antiguos Guarda en archive-targets.csv los logs de más de N días
Archivado Mueve los logs antiguos a otra carpeta
Ejecución de ensayo Con -Preview, no mueve nada y solo muestra el plan
Registro de ejecución Guarda el transcript, el JSON de resumen y el CSV de resultados

Cabe señalar que no se elimina nada. En esta primera entrega de aplicación práctica, nos limitamos al «movimiento», que es más seguro que la eliminación.

3. Estructura de carpetas

Como ejemplo, usaremos la siguiente estructura:

C:\Ops
  Invoke-LogMaintenance.ps1
  log-maintenance.json

C:\App\Logs
  app.log
  batch.log
  old
    app-202401.log

C:\App\Reports
  20260602-030000
    log-hits.csv
    archive-targets.csv
    archive-result.csv
    summary.json
    transcript.txt

C:\App\Archive
  20260602-030000
    old
      app-202401.log

Separar el script principal del archivo de configuración facilita el cambio entre entornos. Puede operar cambiando solo la ruta, por ejemplo C:\Test\Logs en el entorno de desarrollo y D:\App\Logs en producción.

4. Crear el archivo de configuración

Primero creamos log-maintenance.json.

{
  "LogPath": "C:\\App\\Logs",
  "OutputPath": "C:\\App\\Reports",
  "Days": 7,
  "Patterns": [
    "ERROR",
    "WARN",
    "FATAL"
  ],
  "ArchiveDays": 90,
  "ArchivePath": "C:\\App\\Archive"
}

Su significado es el siguiente:

Elemento Significado
LogPath Carpeta de logs a investigar
OutputPath Destino de salida de los informes
Days Cuántos días recientes de logs investigar
Patterns Cadenas o patrones de búsqueda
ArchiveDays A partir de cuántos días de antigüedad se archiva un log
ArchivePath Carpeta de destino del archivado

Al usar JSON, puede cambiar las condiciones sin editar el script principal.

En PowerShell, ConvertFrom-Json permite tratar el JSON como un objeto. A la inversa, cuando se quiere guardar el resultado del procesamiento en JSON, se usa ConvertTo-Json. ConvertTo-Json es un cmdlet que convierte un objeto en una cadena JSON; cuando se trabaja con jerarquías profundas, es importante especificar -Depth.

Patterns se interpreta como expresión regular

Hay un punto que conviene advertir de antemano. El valor de Patterns se pasa finalmente a Select-String -Pattern. Por defecto, Select-String interpreta este valor como una expresión regular.

Es decir, si escribe ERROR., esto significa «ERROR seguido de cualquier carácter», por lo que también coincide con ERRORS o ERROR:. En cambio, no coincide con una línea que solo contenga ERROR. Del mismo modo, una cadena que contenga barras invertidas, como C:\App, se interpreta tal cual como caracteres de control de la expresión regular.

  • Si quiere usarlo como expresión regular: escríbalo tal cual. Puede escribir, por ejemplo, "ERROR|FATAL" o "\[ERROR\]"
  • Si quiere que coincida con la cadena literal: introduzca en el JSON el resultado de [regex]::Escape("ERROR."), o añada -SimpleMatch al Select-String del lado del script

Además, por defecto Select-String no distingue entre mayúsculas y minúsculas: recoge tanto las líneas con error como las que tienen ERROR. Si desea que se distingan, añada -CaseSensitive.

5. Empezar solo con la lectura

En lugar de escribir directamente el procesamiento de movimiento, empezamos limitándonos a buscar los logs y volcarlos en un CSV.

$config = Get-Content .\log-maintenance.json -Raw -Encoding UTF8 | ConvertFrom-Json

$since = (Get-Date).AddDays(-[int]$config.Days)

$files = Get-ChildItem -LiteralPath $config.LogPath -Filter *.log -File -Recurse |
  Where-Object { $_.LastWriteTime -ge $since }

$files |
  Select-Object FullName, Length, LastWriteTime

A continuación, buscamos dentro del contenido de los logs.

$patterns = [string[]]$config.Patterns

Select-String -LiteralPath ($files | Select-Object -ExpandProperty FullName) -Pattern $patterns |
  Select-Object Path, LineNumber, Pattern, Line |
  Export-Csv .\log-hits.csv -NoTypeInformation -Encoding UTF8

Hasta aquí, todo es solo lectura. Incluso al probarlo contra la carpeta de producción, deténgase primero en esta etapa.

6. Listar los logs antiguos

A continuación, listamos los objetos a archivar.

$limit = (Get-Date).AddDays(-[int]$config.ArchiveDays)

$targets = Get-ChildItem -LiteralPath $config.LogPath -Filter *.log -File -Recurse |
  Where-Object { $_.LastWriteTime -lt $limit } |
  Sort-Object LastWriteTime

$targets |
  Select-Object FullName, Length, LastWriteTime |
  Export-Csv .\archive-targets.csv -NoTypeInformation -Encoding UTF8

Tampoco en esta etapa se mueve nada todavía. Revise archive-targets.csv para comprobar que los objetos no son demasiados y que la carpeta no está equivocada.

7. Separar el procesamiento de cambios en una función

Los procesos de cambio, como el movimiento, se separan del procesamiento de lectura.

En PowerShell, al añadir SupportsShouldProcess a una función, esta puede usar -WhatIf y -Confirm. -WhatIf muestra «qué se va a cambiar» sin ejecutarlo realmente, y -Confirm es el mecanismo para pedir confirmación antes de ejecutar. Puede consultar los detalles en about_Functions_CmdletBindingAttribute de Microsoft Learn.

function Move-OldLogFile {
  [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = "Medium")]
  param(
    [Parameter(Mandatory)]
    [System.IO.FileInfo[]]$File,

    [Parameter(Mandatory)]
    [string]$SourceRoot,

    [Parameter(Mandatory)]
    [string]$ArchiveRoot
  )

  foreach ($item in $File) {
    $relativePath = [System.IO.Path]::GetRelativePath($SourceRoot, $item.FullName)
    $destination = Join-Path $ArchiveRoot $relativePath
    $destinationDirectory = Split-Path -Path $destination -Parent

    if ($PSCmdlet.ShouldProcess($item.FullName, "Move to $destination")) {
      if (-not [System.IO.Directory]::Exists($destinationDirectory)) {
        [System.IO.Directory]::CreateDirectory($destinationDirectory) | Out-Null
      }

      Move-Item -LiteralPath $item.FullName -Destination $destination -ErrorAction Stop

      [pscustomobject]@{
        Source      = $item.FullName
        Destination = $destination
        Status      = "Moved"
        Message     = ""
      }
    }
    else {
      [pscustomobject]@{
        Source      = $item.FullName
        Destination = $destination
        Status      = "Preview"
        Message     = ""
      }
    }
  }
}

El punto clave es colocar $PSCmdlet.ShouldProcess() justo antes de Move-Item. La decisión de si se realiza el cambio se ubica justo antes del cambio, no fuera de la función.

8. Script completo

Esta es la versión completa que reúne todo lo anterior. El nombre del archivo es Invoke-LogMaintenance.ps1.

# Invoke-LogMaintenance.ps1
#Requires -Version 7.0

[CmdletBinding()]
param(
  [ValidateNotNullOrEmpty()]
  [string]$ConfigPath = ".\log-maintenance.json",

  [switch]$Preview,

  [switch]$SkipArchive,

  [switch]$SkipTranscript
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

function Ensure-Directory {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)]
    [string]$Path
  )

  if (-not [System.IO.Directory]::Exists($Path)) {
    [System.IO.Directory]::CreateDirectory($Path) | Out-Null
  }
}

function Import-LogMaintenanceConfig {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)]
    [string]$Path
  )

  if (-not (Test-Path -LiteralPath $Path)) {
    throw "Config file not found: $Path"
  }

  $config = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json

  foreach ($name in @("LogPath", "OutputPath", "Days", "Patterns", "ArchiveDays", "ArchivePath")) {
    if (-not ($config.PSObject.Properties.Name -contains $name)) {
      throw "Config value missing: $name"
    }
  }

  if ([string]::IsNullOrWhiteSpace([string]$config.LogPath)) {
    throw "LogPath is empty."
  }

  if (-not (Test-Path -LiteralPath $config.LogPath)) {
    throw "LogPath not found: $($config.LogPath)"
  }

  if ([string]::IsNullOrWhiteSpace([string]$config.OutputPath)) {
    throw "OutputPath is empty."
  }

  if ([string]::IsNullOrWhiteSpace([string]$config.ArchivePath)) {
    throw "ArchivePath is empty."
  }

  if (@($config.Patterns).Count -eq 0) {
    throw "Patterns is empty."
  }

  if ([int]$config.Days -lt 1) {
    throw "Days must be 1 or greater."
  }

  if ([int]$config.ArchiveDays -lt 1) {
    throw "ArchiveDays must be 1 or greater."
  }

  return $config
}

function Export-CsvWithHeader {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)]
    [object[]]$InputObject,

    [Parameter(Mandatory)]
    [string]$Path,

    [Parameter(Mandatory)]
    [string[]]$Header
  )

  if ($InputObject.Count -gt 0) {
    $InputObject |
      Export-Csv -LiteralPath $Path -NoTypeInformation -Encoding UTF8
  }
  else {
    ($Header -join ",") |
      Set-Content -LiteralPath $Path -Encoding UTF8
  }
}

function Get-LogHit {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)]
    [string]$LogPath,

    [Parameter(Mandatory)]
    [ValidateRange(1, 3650)]
    [int]$Days,

    [Parameter(Mandatory)]
    [string[]]$Pattern
  )

  $since = (Get-Date).AddDays(-$Days)

  $files = @(
    Get-ChildItem -LiteralPath $LogPath -Filter *.log -File -Recurse -ErrorAction Stop |
      Where-Object { $_.LastWriteTime -ge $since }
  )

  Write-Verbose "Recent log files: $($files.Count)"

  if ($files.Count -eq 0) {
    return @()
  }

  $paths = $files | Select-Object -ExpandProperty FullName

  Select-String -LiteralPath $paths -Pattern $Pattern -ErrorAction Stop |
    ForEach-Object {
      [pscustomobject]@{
        Path       = $_.Path
        LineNumber = $_.LineNumber
        Pattern    = $_.Pattern
        Line       = $_.Line.Trim()
      }
    }
}

function Get-OldLogFile {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)]
    [string]$LogPath,

    [Parameter(Mandatory)]
    [ValidateRange(1, 3650)]
    [int]$ArchiveDays
  )

  $limit = (Get-Date).AddDays(-$ArchiveDays)

  Get-ChildItem -LiteralPath $LogPath -Filter *.log -File -Recurse -ErrorAction Stop |
    Where-Object { $_.LastWriteTime -lt $limit } |
    Sort-Object LastWriteTime
}

function Move-OldLogFile {
  [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = "Medium")]
  param(
    [Parameter(Mandatory)]
    [System.IO.FileInfo[]]$File,

    [Parameter(Mandatory)]
    [string]$SourceRoot,

    [Parameter(Mandatory)]
    [string]$ArchiveRoot
  )

  foreach ($item in $File) {
    $relativePath = [System.IO.Path]::GetRelativePath($SourceRoot, $item.FullName)
    $destination = Join-Path $ArchiveRoot $relativePath
    $destinationDirectory = Split-Path -Path $destination -Parent

    if (Test-Path -LiteralPath $destination) {
      $name = [System.IO.Path]::GetFileNameWithoutExtension($item.Name)
      $ext = $item.Extension
      $destination = Join-Path $destinationDirectory ("{0}_{1:yyyyMMddHHmmss}{2}" -f $name, $item.LastWriteTime, $ext)
    }

    if ($PSCmdlet.ShouldProcess($item.FullName, "Move to $destination")) {
      Ensure-Directory -Path $destinationDirectory

      Move-Item -LiteralPath $item.FullName -Destination $destination -ErrorAction Stop

      [pscustomobject]@{
        Source      = $item.FullName
        Destination = $destination
        Status      = "Moved"
        Message     = ""
      }
    }
    else {
      [pscustomobject]@{
        Source      = $item.FullName
        Destination = $destination
        Status      = "Preview"
        Message     = ""
      }
    }
  }
}

$config = Import-LogMaintenanceConfig -Path $ConfigPath

$runStamp = Get-Date -Format "yyyyMMdd-HHmmss"
$reportDir = Join-Path ([string]$config.OutputPath) $runStamp
Ensure-Directory -Path $reportDir

$transcriptStarted = $false
$transcriptPath = Join-Path $reportDir "transcript.txt"

try {
  if (-not $SkipTranscript) {
    Start-Transcript -Path $transcriptPath -Force | Out-Null
    $transcriptStarted = $true
  }

  Write-Host "Report directory: $reportDir"

  $hits = @(
    Get-LogHit `
      -LogPath ([string]$config.LogPath) `
      -Days ([int]$config.Days) `
      -Pattern ([string[]]$config.Patterns)
  )

  $hitCsv = Join-Path $reportDir "log-hits.csv"
  Export-CsvWithHeader `
    -InputObject $hits `
    -Path $hitCsv `
    -Header @("Path", "LineNumber", "Pattern", "Line")

  $oldFiles = @(
    Get-OldLogFile `
      -LogPath ([string]$config.LogPath) `
      -ArchiveDays ([int]$config.ArchiveDays)
  )

  $archiveTargets = @(
    $oldFiles |
      Select-Object FullName, Length, LastWriteTime
  )

  $archiveTargetCsv = Join-Path $reportDir "archive-targets.csv"
  Export-CsvWithHeader `
    -InputObject $archiveTargets `
    -Path $archiveTargetCsv `
    -Header @("FullName", "Length", "LastWriteTime")

  $moveResults = @()

  if ($SkipArchive) {
    Write-Host "Archive skipped."
  }
  elseif ($oldFiles.Count -eq 0) {
    Write-Host "No archive targets."
  }
  else {
    $archiveRunRoot = Join-Path ([string]$config.ArchivePath) $runStamp

    $moveResults = @(
      Move-OldLogFile `
        -File $oldFiles `
        -SourceRoot ([string]$config.LogPath) `
        -ArchiveRoot $archiveRunRoot `
        -WhatIf:$Preview
    )
  }

  $archiveResultCsv = Join-Path $reportDir "archive-result.csv"
  Export-CsvWithHeader `
    -InputObject $moveResults `
    -Path $archiveResultCsv `
    -Header @("Source", "Destination", "Status", "Message")

  $summary = [pscustomobject]@{
    CheckedAt          = (Get-Date).ToString("s")
    ComputerName       = $env:COMPUTERNAME
    LogPath            = [string]$config.LogPath
    ReportDirectory    = $reportDir
    HitCount           = $hits.Count
    ArchiveTargetCount = $oldFiles.Count
    ArchiveResultCount = $moveResults.Count
    Preview            = [bool]$Preview
    SkipArchive        = [bool]$SkipArchive
  }

  $summaryPath = Join-Path $reportDir "summary.json"
  $summary |
    ConvertTo-Json -Depth 5 |
    Set-Content -LiteralPath $summaryPath -Encoding UTF8

  Write-Host "Finished."
  Write-Host "Hits: $($hits.Count)"
  Write-Host "Archive targets: $($oldFiles.Count)"
}
catch {
  $errorPath = Join-Path $reportDir "error.txt"
  $_ | Out-String | Set-Content -LiteralPath $errorPath -Encoding UTF8
  Write-Error "Failed: $($_.Exception.Message)"
  exit 1
}
finally {
  if ($transcriptStarted) {
    Stop-Transcript | Out-Null
  }
}

9. Ejemplos de ejecución

Primero, realizamos solo la investigación de logs sin ejecutar el archivado.

.\Invoke-LogMaintenance.ps1 -ConfigPath .\log-maintenance.json -SkipArchive

A continuación, comprobamos el plan de archivado.

.\Invoke-LogMaintenance.ps1 -ConfigPath .\log-maintenance.json -Preview

Al añadir -Preview, el procesamiento de movimiento de los logs antiguos se trata como -WhatIf.

En este punto, los archivos que hay que revisar son estos tres:

  • log-hits.csv
  • archive-targets.csv
  • archive-result.csv

Si no hay problemas, ejecute quitando -Preview.

.\Invoke-LogMaintenance.ps1 -ConfigPath .\log-maintenance.json

Si desea ver más detalle, añada -Verbose.

.\Invoke-LogMaintenance.ps1 -ConfigPath .\log-maintenance.json -Preview -Verbose

10. Archivos generados

Al ejecutarse, se crea debajo de OutputPath una carpeta con fecha y hora, similar a C:\App\Reports\20260602-030000.

Dentro se generan los siguientes archivos:

Archivo Contenido
log-hits.csv Líneas detectadas como error o advertencia
archive-targets.csv Logs antiguos que son objeto de archivado
archive-result.csv Resultado del movimiento, o resultado del modo Preview
summary.json Resumen de las cantidades y de las condiciones de ejecución
transcript.txt Registro de la sesión de PowerShell
error.txt Detalles en caso de error

Start-Transcript es un cmdlet que registra en un archivo de texto los comandos y la salida de consola de una sesión de PowerShell. En un script operativo, esto facilita comprobar después «cuándo, en qué condiciones y qué resultado se obtuvo».

La «forma correcta» de los archivos generados

La primera vez, más que comprobar si se generó el archivo, hay que revisar si el contenido es el esperado. El contenido de los logs es ficticio, pero el orden de las columnas y los elementos es tal como lo produce el script de este artículo.

log-hits.csv tiene 4 columnas: Path / LineNumber / Pattern / Line. Por defecto, Export-Csv rodea todos los campos con ".

"Path","LineNumber","Pattern","Line"
"C:\App\Logs\app.log","128","ERROR","2026-06-02 02:14:51 [ERROR] OrderService: timeout while calling /api/stock"
"C:\App\Logs\app.log","301","WARN","2026-06-02 02:41:03 [WARN] OrderService: retry 1/3"
"C:\App\Logs\batch.log","57","FATAL","2026-06-02 03:00:12 [FATAL] nightly batch aborted"

En la columna Pattern se guarda tal cual la cadena de patrón que produjo la coincidencia. Como corresponde con los valores escritos en Patterns del archivo de configuración, se puede usar para contabilizar «con qué palabra clave y cuántos resultados aparecieron».

archive-targets.csv tiene 3 columnas: FullName / Length / LastWriteTime.

"FullName","Length","LastWriteTime"
"C:\App\Logs\old\app-202401.log","10485760","2024/01/31 23:59:58"

archive-result.csv tiene 4 columnas: Source / Destination / Status / Message. Cuando se usa -Preview, Status es Preview; cuando se ejecuta realmente, es Moved.

"Source","Destination","Status","Message"
"C:\App\Logs\old\app-202401.log","C:\App\Archive\20260602-030000\old\app-202401.log","Moved",""

La notación de fecha y hora varía según el entorno, porque Export-Csv la convierte a texto siguiendo la configuración regional del sistema operativo. Si va a pasar el CSV a un sistema posterior, compruebe este punto de antemano.

summary.json es un resumen de las cantidades y de las condiciones de ejecución. Las rutas se escapan como \\ según la especificación de JSON.

{
  "CheckedAt": "2026-06-02T03:00:07",
  "ComputerName": "OPS-01",
  "LogPath": "C:\\App\\Logs",
  "ReportDirectory": "C:\\App\\Reports\\20260602-030000",
  "HitCount": 3,
  "ArchiveTargetCount": 1,
  "ArchiveResultCount": 1,
  "Preview": false,
  "SkipArchive": false
}

Para la monitorización o la revisión diaria, resulta cómodo que baste con mirar este summary.json. Puede operar de forma que solo abra el CSV los días en que HitCount se dispare o ArchiveTargetCount aumente de repente.

Cabe señalar que el CSV se crea incluso cuando no hay ningún objeto. Esto se debe a que Export-CsvWithHeader está diseñada para escribir un archivo que solo contiene la fila de cabecera. Es un diseño pensado para no confundir «no hay archivo» con «hubo 0 coincidencias».

11. Ejecutar periódicamente con el Programador de tareas

Si la ejecución manual no presenta problemas, puede programar la ejecución periódica con el Programador de tareas.

Primero, un ejemplo que se ejecuta todos los días a las 3:00 de la madrugada.

$scriptPath = "C:\Ops\Invoke-LogMaintenance.ps1"
$configPath = "C:\Ops\log-maintenance.json"

$action = New-ScheduledTaskAction `
  -Execute "pwsh.exe" `
  -Argument "-NoProfile -File `"$scriptPath`" -ConfigPath `"$configPath`"" `
  -WorkingDirectory "C:\Ops"

$trigger = New-ScheduledTaskTrigger -Daily -At 3:00

Register-ScheduledTask `
  -TaskName "AppLogMaintenance" `
  -Action $action `
  -Trigger $trigger `
  -Description "Collect app log errors and archive old logs"

New-ScheduledTaskAction crea un objeto que representa el comando que ejecutará la tarea, y New-ScheduledTaskTrigger crea la condición de inicio (diaria, semanal, al iniciar sesión, etc.). Por último, Register-ScheduledTask registra la tarea en el equipo local.

Comprobar que el registro se realizó correctamente

Que el comando de registro se ejecute sin errores no significa todavía que «funcione». Antes de esperar a la ejecución, conviene revisar visualmente el contenido registrado.

Si usa la interfaz gráfica, abra el Programador de tareas (taskschd.msc) y seleccione «Biblioteca del Programador de tareas» en el panel izquierdo; ahí aparecerá en la lista la tarea AppLogMaintenance que acaba de registrar. Las columnas que hay que revisar son las siguientes:

Columna Cómo interpretarla
Estado Si dice «Preparado», está activa. Si dice «Deshabilitada», no se ejecutará aunque llegue el desencadenador
Desencadenador Comprueba si la condición es la deseada, por ejemplo «Todos los días a las 3:00»
Próxima ejecución La hora prevista más cercana. Si aparece vacía, sospeche del desencadenador o del estado habilitado/deshabilitado
Resultado de la última ejecución Se rellena después de ejecutarse. 0x0 significa éxito, 0x1 es un error general, y 0x41301 significa que está en ejecución

Si estas columnas no aparecen en la lista, haga clic con el botón derecho sobre los encabezados de columna y añada las columnas que desee mostrar.

Lo mismo se puede comprobar desde PowerShell. En equipos donde no se puede abrir la interfaz gráfica, esta vía es más rápida.

# Ver el contenido registrado
Get-ScheduledTask -TaskName "AppLogMaintenance" |
  Select-Object TaskName, TaskPath, State

# Ver el resultado de la ejecución
Get-ScheduledTaskInfo -TaskName "AppLogMaintenance" |
  Select-Object TaskName, LastRunTime, LastTaskResult, NextRunTime

Si LastTaskResult no es 0, primero revise error.txt y transcript.txt en la carpeta de informes.

Ahora bien, si la propia tarea no ha llegado a iniciarse, el script no ha ejecutado ni una sola línea, por lo que ni siquiera se crea la carpeta de informes. En ese caso, en lugar de las propiedades que se abren al hacer clic derecho sobre la tarea, revise la pestaña «Historial» que aparece en la parte inferior al seleccionar la tarea en la lista. Como el historial puede estar deshabilitado por defecto, actívelo con «Habilitar todo el historial de tareas» en el panel derecho, y luego compruébelo iniciándolo manualmente con «Ejecutar» del clic derecho.

En el uso en producción, conviene comprobar también los siguientes puntos:

  • El usuario de ejecución tiene permiso de lectura sobre la carpeta de logs
  • Tiene permiso de escritura sobre el destino de archivado
  • La ruta de pwsh.exe está correctamente resuelta
  • Cumple con la directiva de ejecución y las reglas de firma del script
  • La ejecución manual y la ejecución por tarea producen el mismo resultado
  • En caso de fallo, se puede revisar error.txt y el historial de la tarea

12. Problemas frecuentes

Síntoma Causa Solución
No se encuentran los logs LogPath está equivocado Comprobar con Test-Path y Get-ChildItem
El CSV queda vacío No hay logs en el periodo indicado Ampliar Days y comprobar
El texto aparece con caracteres corruptos La codificación del log es distinta de la esperada Comprobar la codificación de entrada y salida
No funciona con la tarea El usuario de ejecución o la carpeta de trabajo son distintos Comprobar WorkingDirectory y los permisos
Hay demasiados objetos a archivar ArchiveDays es demasiado corto Revisar archive-targets.csv y ajustar
El destino de movimiento no es el esperado No se ha entendido la regla de conservación de la ruta relativa Comprobar Destination con -Preview
Solo falla en producción Diferencias de permisos, directivas o archivos bloqueados Comprobar error.txt y transcript.txt

En particular, al ejecutarlo con el Programador de tareas, «cuando lo ejecuta usted mismo» y «el usuario de ejecución de la tarea» pueden ser distintos. Si funciona de forma manual pero falla como tarea, sospeche primero de los permisos y de la carpeta de trabajo.

13. Consideraciones al modificar el script

Este script está pensado más para adaptarlo poco a poco al entorno real que para usarlo tal cual. A continuación se presentan ejemplos habituales de modificación.

Qué se quiere hacer Dónde modificar
También incluir .txt Cambiar Get-ChildItem -Filter *.log
Ver también unas líneas antes y después de ERROR Obtener las líneas cercanas con Get-Content a partir del resultado de Select-String
Comprimir antes de archivar Añadir Compress-Archive antes de Move-OldLogFile
Enviar notificación por correo Añadir un procesamiento de notificación basado en summary.json
Separar la configuración por aplicación Preparar varios JSON y separar las tareas
Automatizar también la eliminación Confirmar primero con la operación de movimiento durante un tiempo, y luego considerarlo

No obstante, es más seguro no incorporarlo todo desde el principio. En un script operativo, lo importante no es que tenga muchas funciones, sino que se pueda rastrear qué ocurrió cuando falla.

Solo para la notificación por correo, decida antes el método

«Quiero notificación por correo» es una petición frecuente, pero este es el único punto en el que hay que decidir el método antes de implementarlo.

Send-MailMessage de PowerShell está marcado explícitamente como «obsolete» (obsoleto) en Microsoft Learn, y se recomienda no usarlo porque no garantiza una conexión segura al servidor SMTP. No existe un cmdlet sucesor directo dentro de PowerShell; como alternativas se mencionan bibliotecas como MailKit, o bien, en entornos de Exchange Online, Send-MgUserMail del SDK de Microsoft Graph PowerShell.

El método varía según el entorno.

Entorno Método de notificación
Hay un relé SMTP interno y no se requiere autenticación ni TLS Send-MailMessage funciona, pero conviene encapsularlo en una sola función previendo su futura eliminación
Se requiere SMTP con autenticación o TLS Usar una biblioteca como MailKit
Entorno Microsoft 365 Usar Microsoft Graph (Send-MgUserMail)
No es necesario que sea correo Usar un webhook como el de Teams, o integrar mediante archivos con la plataforma de monitorización

Sea cual sea el método, limite el procesamiento de notificación a leer summary.json y enviarlo, y manténgalo separado del procesamiento principal. En la práctica, resulta eficaz dejarlo de modo que, aunque la notificación falle, la investigación de logs y el archivado ya se hayan completado.

14. Lista de verificación operativa en el terreno

Antes de ejecutar el script de PowerShell de forma periódica, compruebe los siguientes puntos:

  • Se ejecutó primero solo el procesamiento de lectura con -SkipArchive
  • A continuación, se comprobó el plan de movimiento con -Preview
  • Los objetos de archive-targets.csv eran razonables
  • El Destination de archive-result.csv era el esperado
  • Quedó constancia con fecha y hora en la carpeta de destino
  • Se comprobó que error.txt se genera en caso de error
  • Se comprobaron los permisos del usuario de ejecución de la tarea
  • Se comprobaron la directiva de ejecución, la firma y las normas internas
  • Se opera primero con el movimiento, no con la eliminación directa
  • Se ha decidido el destino de restauración por si hace falta recuperar algo

15. Resumen

Aunque hablemos de «aplicación práctica» de PowerShell, no hace falta usar mucha sintaxis complicada. Lo que resulta eficaz en el trabajo real es construir un patrón como este:

  • Separar la configuración en JSON
  • Construir primero el procesamiento de lectura
  • Dejar constancia con CSV y JSON
  • Separar el procesamiento de cambios en una función
  • Preparar un ensayo equivalente a -WhatIf
  • Hacer que se pueda rastrear con el transcript y error.txt
  • Confirmar con la ejecución manual antes de programar la ejecución periódica

El script de este artículo tiene como tema la investigación de logs y el archivado, pero el enfoque se puede aplicar a otras tareas.

  • Organización de archivos
  • Generación de informes
  • Agregación de CSV
  • Sustitución de procesos por lotes
  • Inventario de activos antiguos
  • Verificación operativa diaria y mensual

PowerShell resulta útil incluso con comandos de una sola línea, pero si se va a usar en el trabajo, es más seguro respetar el siguiente orden:

Observar → Registrar → Ensayar → Ejecutar → Dejar constancia

Si mantiene esta forma de trabajar, PowerShell deja de ser una simple herramienta para acortar tareas y pasa a funcionar como una pequeña aplicación de negocio para estabilizar la operación.

Referencias

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

Al automatizar la investigación de logs con PowerShell, ¿por dónde conviene empezar?
No conviene escribir directamente procesos de cambio como mover o eliminar archivos; primero se construye solo el procesamiento de lectura. Complete primero la parte que busca los logs objetivo con Get-ChildItem, extrae las líneas de error con Select-String y las exporta a un CSV. Incluso al probarlo contra la carpeta de producción, deténgase en esta etapa de lectura y revise los resultados. Después, avance de forma gradual hacia la elaboración del listado de objetos a archivar y, finalmente, hacia la creación de una función para el procesamiento de movimiento.
¿Cómo se incorpora -WhatIf de PowerShell en un script?
Al agregar SupportsShouldProcess al atributo CmdletBinding de una función, esa función puede usar -WhatIf y -Confirm. El punto clave es colocar $PSCmdlet.ShouldProcess() justo antes del procesamiento que realiza el cambio real (como Move-Item), de modo que la decisión de si se cambia o no se tome justo antes del cambio. En el script de este artículo, el switch -Preview se pasa como -WhatIf:$Preview, lo que permite comprobar solo el plan sin mover realmente los archivos.
¿Por qué un script de PowerShell funciona al ejecutarlo manualmente pero falla en el Programador de tareas?
La causa típica es que el usuario con el que se ejecuta la tarea es distinto del usuario con el que usted la probó manualmente. Compruebe si ese usuario tiene permiso de lectura sobre la carpeta de logs y de escritura sobre el destino de archivado, si la carpeta de trabajo (WorkingDirectory) es correcta, si la ruta a pwsh.exe está resuelta y si el script cumple con la política de ejecución y las reglas de firma. Es importante que, en caso de fallo, se pueda investigar mediante error.txt, transcript.txt y el historial de la tarea.
¿Es recomendable automatizar también la eliminación de logs antiguos?
No se recomienda automatizar la eliminación desde el principio. El script de este artículo también se limita al «movimiento», que es más seguro que la eliminación. Primero, opere con el movimiento durante un período determinado, revise evidencias como archive-targets.csv y archive-result.csv, y solo después de confirmar que no hay problemas, considere automatizar la eliminación. También es importante decidir de antemano el destino de restauración por si hace falta recuperar algo.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog