Why Matomo's log importer fails: invalid log lines, JSON errors, and silent piped logging

Matomo's import_logs.py rarely tells you what's actually wrong. A JSONDecodeError almost always means --url is pointing at the wrong place, every line counted as an 'invalid log line' means your log format doesn't match, and a piped import that records nothing is usually just buffering. Here's how to read each failure and fix it on Matomo 5.x.

If you've run Matomo's log importer and watched it die on a JSONDecodeError before it read a single line, or watched it finish cleanly and announce that every one of your lines was an "invalid log line", or wired up Apache piped logging that imports nothing at all on a quiet site, none of those are bugs in your server. They look like three unrelated failures. They're three configuration problems, and each one hands you a message that points the wrong way.

The importer is import_logs.py, shipped in misc/log-analytics/ with every Matomo release (the standalone copy lives at matomo-org/matomo-log-analytics). It's one of the more reliable ways to feed Matomo without a JavaScript tag, the server-side cousin of the HTTP Tracking API that reads your existing access logs instead of waiting for a browser to fire a beacon. It's also unusually good at hiding the real cause of a failure behind a misleading one. Here's how to read the three you're most likely to hit.

TL;DR

All three are configuration problems, not bugs in your server. A JSONDecodeError means --url is hitting something that returns HTML instead of your Matomo install, usually the tracked site itself or an http-to-https redirect. "Invalid log lines" for every entry means --log-format-name doesn't match your access log; a standard combined log wants ncsa_extended, not one of the %v-first formats. A piped import that records nothing on a quiet site is just the recorder batching hits, so set --recorder-max-payload-size=1.

The JSON error: --url is pointing at the wrong place

The most common failure is a Python traceback that ends in:

text
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

often with a second, scarier-looking line under it: AttributeError: 'str' object has no attribute 'decode'. Ignore that one. It's the importer's own error reporting tripping over itself while trying to print the message it should be showing you, which is "Matomo returned an invalid response." Depending on which revision of import_logs.py you're running, that helpful line gets swallowed and the JSON traceback is all you're left with.

The mechanism is simple once you see it. --url is the address of your Matomo installation, not the website whose logs you're importing. The importer POSTs to Matomo's Tracking API and expects JSON back. Point it somewhere that returns an HTML page instead (the marketing site you're tracking, a login screen, a 404) and Python tries to parse <!DOCTYPE html> as JSON and chokes on character zero. That's the whole bug.

Three variations produce the identical error:

  • --url is aimed at the tracked site instead of Matomo. Point it at your Matomo install, e.g. --url=https://analytics.example.com or https://www.example.com/matomo, not https://www.example.com.
  • The URL is http and Matomo redirects it to https. The redirect response is an HTML page, not JSON, and that's enough to break the parse. This is the exact trap Claudio Kuenzler documented: a cron job kept an old http URL after a migration, and the redirect turned into a parse error. Use the final https address Matomo actually serves on.
  • Something between you and Matomo is returning HTML. A reverse proxy handing back an error page produces the same symptom, the same way a proxy returning a 502 instead of Matomo does.

Don't try to confirm any of this by opening the base URL in a browser. The Matomo base URL normally serves the login screen or dashboard, not the Tracking API, so a page of HTML there tells you nothing either way. Test it the way the importer does: run a one-line import with --debug and look at the response Matomo actually sends back.

If you've seen "Matomo returned an invalid response" elsewhere, for instance from cron archiving, it's the same family of problem: a request to Matomo that came back as something other than the JSON the caller expected.

"Invalid log lines": your format doesn't match your logs

The second failure is quieter and more demoralizing. The import runs to completion, then the summary says something like 0 requests imported successfully and 244 invalid log lines. Nothing went wrong with the run. The parser just couldn't match a single line against the format you told it to expect, so it dropped all of them.

The usual culprit: you set --log-format-name=common_complete (or common_vhost), but your Apache LogFormat doesn't start with the virtual host. Both of those formats expect the virtual host (%v) as the very first field. The standard Apache "combined" line, like the Nginx default, starts with %h, the client IP, and has no %v anywhere. The first field doesn't match, the whole regex fails, and every line lands in the invalid pile.

The four formats most people choose between, sorted by what the log line actually starts with:

Log line starts withUse this format
%h (client IP), request, status, no referrer or user-agentcommon
%h plus "%{Referer}i" "%{User-Agent}i"ncsa_extended
%v (vhost) first, then the common fieldscommon_vhost
%v (vhost) first, then the extended fieldscommon_complete

ncsa_extended is Apache's combined format and the Nginx default, and it carries no virtual host. It's the one most people actually want. The two %v-first formats are not interchangeable: common_vhost is the vhost plus the common fields, common_complete is the vhost plus the extended fields (referrer and user-agent). Only reach for either if %v really is your first field.

Other sources have their own format names (nginx_json, w3c_extended, iis, s3, elb, amazon_cloudfront, and more). Run import_logs.py --help for the current list rather than guessing.

So the fix is one of two moves. Switch to --log-format-name=ncsa_extended to match your existing combined log, or add %v to the front of your Apache LogFormat if you genuinely want per-vhost imports. The vhost-first formats earn their keep when one log feeds several sites or measurables and you want the importer to route each request to the right one by hostname. Just don't reach for them unless the field is actually there.

Silent piped logging: it's buffering, not broken

The third one looks like a ghost. You move from manual imports to Apache piped logging, CustomLog "|.../import_logs.py ...", the debug log says "Launched recorder" a few times, and then nothing. No visits. The same command run by hand against the same log works fine.

Assuming the manual run worked and Apache actually launched the process, the usual suspect on a quiet site is batching, not a broken pipe. In piped mode the importer is a long-lived process, and its recorder batches hits into a single bulk Tracking API request before sending. --recorder-max-payload-size sets how many log entries go into each request. On a busy site the batch fills fast and you never notice. On a quiet one it can sit partly full for a long time, which is why visits show up in sudden bursts, or only after you restart Apache and flush the buffer.

Set --recorder-max-payload-size=1 and each request goes out the moment it arrives. You trade a little efficiency for immediacy, which is the trade you want on a low-traffic site where "did it work?" is the whole question. If setting it to 1 still gets you nothing, the problem is lower down: the Apache user can't execute the script, the trailing - is missing, or it can't read the auth file.

The fix, in order

Don't debug inside Apache. Get a manual run clean first, then wire up the pipe.

  1. Run a manual import of a small log file so you can actually read the summary:

    bash
    python3 /path/to/matomo/misc/log-analytics/import_logs.py \
      --url=https://analytics.example.com \
      --idsite=1 \
      --token-auth=YOUR_TOKEN \
      /var/log/apache2/access.log

    --token-auth on the command line is fine for a one-off test like this; for the permanent pipe, move the token into a config file (see below).

  2. Fix --url so it points at your Matomo install over https, with no redirect in the way. Make --idsite a number (--idsite=1): it's the numeric site ID, never a site name or shortcode.

  3. Match the format to your logs. Standard combined line with no %v? Use --log-format-name=ncsa_extended. Only reach for common_vhost/common_complete if the virtual host really is the first field.

  4. Re-run and read the summary. You want a non-zero requests imported successfully and 0 invalid log lines. Don't move on until you have both.

  5. Only now wire up piped logging, adding --recorder-max-payload-size=1 and the trailing - so the script reads from stdin:

    bash
    CustomLog "|/path/to/matomo/misc/log-analytics/import_logs.py \
      --url=https://analytics.example.com --idsite=1 \
      --auth-config=/etc/matomo/import-auth.cfg \
      --recorder-max-payload-size=1 --log-format-name=ncsa_extended \
      --output=/var/log/matomo-import.log -" combined

  6. Drop --debug in production. It's invaluable for the manual test and a liability in a piped CustomLog, where it floods the output file and buries any real activity.

How to know it actually worked

  • The manual run reports invalid log lines: 0 and a non-zero count of imported requests. That's the real pass/fail signal: the API took your data.
  • The visits show up under Visitors → Visits Log in Matomo, for the right site. One thing that fools people: server logs are imported with the timestamps in the log, not the time you ran the import. If you're backfilling last month's access log, check last month's date range, not today's.
  • For piped logging on a quiet site, generate a few real requests yourself and confirm they land within a minute or so once --recorder-max-payload-size=1 is set. If they do, the pipe is healthy.

What we'd actually do

Treat the manual run as the only test that matters, and never skip it. Most "import_logs.py not working" tickets come down to one of three things: --url aimed at the tracked site instead of Matomo, a format name that assumes a %v you don't log, or a recorder quietly batching on a low-traffic pipe. Confirm 0 invalid log lines and a non-zero import count against a small file, change one thing at a time, and only then hand the command to a CustomLog. The importer is dependable once it's configured. It's just terrible at telling you which of the three is wrong.

Martez connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. Join the waitlist if that's relevant.